hyperping 0.1.0__py3-none-any.whl
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/__init__.py +151 -0
- hyperping/_incidents_mixin.py +198 -0
- hyperping/_maintenance_mixin.py +195 -0
- hyperping/_monitors_mixin.py +250 -0
- hyperping/_outages_mixin.py +102 -0
- hyperping/_statuspages_mixin.py +226 -0
- hyperping/_version.py +1 -0
- hyperping/client.py +452 -0
- hyperping/endpoints.py +231 -0
- hyperping/exceptions.py +89 -0
- hyperping/models.py +769 -0
- hyperping/py.typed +0 -0
- hyperping-0.1.0.dist-info/METADATA +223 -0
- hyperping-0.1.0.dist-info/RECORD +16 -0
- hyperping-0.1.0.dist-info/WHEEL +4 -0
- hyperping-0.1.0.dist-info/licenses/LICENSE +21 -0
hyperping/client.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""Hyperping API client with retry logic and error handling.
|
|
2
|
+
|
|
3
|
+
This module provides the main :class:`HyperpingClient` class along with
|
|
4
|
+
configuration dataclasses for retry and circuit-breaker behavior.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import random
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from pydantic import SecretStr
|
|
17
|
+
|
|
18
|
+
from hyperping._incidents_mixin import IncidentsMixin
|
|
19
|
+
from hyperping._maintenance_mixin import MaintenanceMixin
|
|
20
|
+
from hyperping._monitors_mixin import MonitorsMixin
|
|
21
|
+
from hyperping._outages_mixin import OutagesMixin
|
|
22
|
+
from hyperping._statuspages_mixin import StatusPagesMixin
|
|
23
|
+
from hyperping._version import __version__
|
|
24
|
+
from hyperping.endpoints import (
|
|
25
|
+
API_BASE,
|
|
26
|
+
)
|
|
27
|
+
from hyperping.exceptions import (
|
|
28
|
+
HyperpingAPIError,
|
|
29
|
+
HyperpingAuthError,
|
|
30
|
+
HyperpingNotFoundError,
|
|
31
|
+
HyperpingRateLimitError,
|
|
32
|
+
HyperpingValidationError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
_DEFAULT_USER_AGENT = f"hyperping-python/{__version__}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class RetryConfig:
|
|
42
|
+
"""Configuration for retry behavior."""
|
|
43
|
+
|
|
44
|
+
max_retries: int = 3
|
|
45
|
+
initial_delay: float = 1.0
|
|
46
|
+
max_delay: float = 30.0
|
|
47
|
+
backoff_factor: float = 2.0
|
|
48
|
+
retry_on_status: tuple[int, ...] = (429, 500, 502, 503, 504)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
DEFAULT_RETRY_CONFIG = RetryConfig()
|
|
52
|
+
|
|
53
|
+
# Maximum time to honor a server-requested Retry-After value (5 minutes)
|
|
54
|
+
_RETRY_AFTER_MAX = 300.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CircuitState(StrEnum):
|
|
58
|
+
"""Circuit breaker states."""
|
|
59
|
+
|
|
60
|
+
CLOSED = "closed" # Normal: requests flow through
|
|
61
|
+
OPEN = "open" # Failing: requests fail fast
|
|
62
|
+
HALF_OPEN = "half_open" # Testing: one request allowed through
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class CircuitBreakerConfig:
|
|
67
|
+
"""Configuration for circuit breaker behavior."""
|
|
68
|
+
|
|
69
|
+
failure_threshold: int = 5
|
|
70
|
+
recovery_timeout: float = 60.0
|
|
71
|
+
half_open_max_calls: int = 1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
DEFAULT_CIRCUIT_BREAKER_CONFIG = CircuitBreakerConfig()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class CircuitBreaker:
|
|
78
|
+
"""Circuit breaker pattern for API calls.
|
|
79
|
+
|
|
80
|
+
States:
|
|
81
|
+
CLOSED → normal operation
|
|
82
|
+
OPEN → fail fast, no API calls made
|
|
83
|
+
HALF_OPEN → allow one trial call; success → CLOSED, failure → OPEN
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, config: CircuitBreakerConfig | None = None) -> None:
|
|
87
|
+
"""Initialize the circuit breaker.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
config: Circuit breaker configuration. Uses defaults if ``None``.
|
|
91
|
+
"""
|
|
92
|
+
self._config = config or DEFAULT_CIRCUIT_BREAKER_CONFIG
|
|
93
|
+
self._state = CircuitState.CLOSED
|
|
94
|
+
self._failure_count = 0
|
|
95
|
+
self._half_open_calls: int = 0
|
|
96
|
+
self._last_failure_time: float | None = None
|
|
97
|
+
self._lock = threading.Lock()
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def state(self) -> str:
|
|
101
|
+
"""Return the current circuit state."""
|
|
102
|
+
return self._state
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def failure_count(self) -> int:
|
|
106
|
+
"""Return the current consecutive failure count."""
|
|
107
|
+
return self._failure_count
|
|
108
|
+
|
|
109
|
+
def call_allowed(self) -> bool:
|
|
110
|
+
"""Check whether a new call is permitted under current state.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
``True`` if a request may proceed, ``False`` if the circuit is open.
|
|
114
|
+
"""
|
|
115
|
+
with self._lock:
|
|
116
|
+
if self._state == CircuitState.CLOSED:
|
|
117
|
+
return True
|
|
118
|
+
if self._state == CircuitState.OPEN:
|
|
119
|
+
if self._last_failure_time is not None:
|
|
120
|
+
elapsed = time.time() - self._last_failure_time
|
|
121
|
+
if elapsed >= self._config.recovery_timeout:
|
|
122
|
+
self._state = CircuitState.HALF_OPEN
|
|
123
|
+
logger.info("Circuit breaker: OPEN → HALF_OPEN (trial call allowed)")
|
|
124
|
+
return True
|
|
125
|
+
return False
|
|
126
|
+
# HALF_OPEN: allow only up to half_open_max_calls trial requests
|
|
127
|
+
if self._half_open_calls < self._config.half_open_max_calls:
|
|
128
|
+
self._half_open_calls += 1
|
|
129
|
+
return True
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
def record_success(self) -> None:
|
|
133
|
+
"""Record a successful call — reset to CLOSED."""
|
|
134
|
+
with self._lock:
|
|
135
|
+
self._half_open_calls = 0
|
|
136
|
+
if self._state != CircuitState.CLOSED:
|
|
137
|
+
logger.info(
|
|
138
|
+
f"Circuit breaker: {self._state} → CLOSED (recovered after "
|
|
139
|
+
f"{self._failure_count} failures)"
|
|
140
|
+
)
|
|
141
|
+
self._state = CircuitState.CLOSED
|
|
142
|
+
self._failure_count = 0
|
|
143
|
+
self._last_failure_time = None
|
|
144
|
+
|
|
145
|
+
def record_failure(self) -> None:
|
|
146
|
+
"""Record a failed call — may open the circuit."""
|
|
147
|
+
with self._lock:
|
|
148
|
+
self._half_open_calls = 0
|
|
149
|
+
self._failure_count += 1
|
|
150
|
+
self._last_failure_time = time.time()
|
|
151
|
+
if self._failure_count >= self._config.failure_threshold:
|
|
152
|
+
if self._state != CircuitState.OPEN:
|
|
153
|
+
logger.warning(
|
|
154
|
+
f"Circuit breaker: {self._state} → OPEN "
|
|
155
|
+
f"(threshold {self._config.failure_threshold} reached)"
|
|
156
|
+
)
|
|
157
|
+
self._state = CircuitState.OPEN
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class HyperpingClient(
|
|
161
|
+
MonitorsMixin, IncidentsMixin, MaintenanceMixin, OutagesMixin, StatusPagesMixin
|
|
162
|
+
):
|
|
163
|
+
"""Client for interacting with Hyperping API.
|
|
164
|
+
|
|
165
|
+
Handles authentication, retry logic, and error mapping.
|
|
166
|
+
|
|
167
|
+
Example:
|
|
168
|
+
>>> client = HyperpingClient(api_key="sk_xxx")
|
|
169
|
+
>>> monitors = client.list_monitors()
|
|
170
|
+
>>> for m in monitors:
|
|
171
|
+
... print(f"{m.name}: {'down' if m.down else 'up'}")
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
DEFAULT_BASE_URL = API_BASE # https://api.hyperping.io
|
|
175
|
+
DEFAULT_TIMEOUT = 30.0
|
|
176
|
+
|
|
177
|
+
def __init__(
|
|
178
|
+
self,
|
|
179
|
+
api_key: str | SecretStr,
|
|
180
|
+
base_url: str | None = None,
|
|
181
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
182
|
+
retry_config: RetryConfig | None = None,
|
|
183
|
+
circuit_breaker_config: CircuitBreakerConfig | None = None,
|
|
184
|
+
user_agent: str | None = None,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Initialize the Hyperping API client.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
api_key: Hyperping API key (starts with ``sk_``). Accepts a plain
|
|
190
|
+
string or a :class:`pydantic.SecretStr`.
|
|
191
|
+
base_url: Override the default API base URL
|
|
192
|
+
(``https://api.hyperping.io``).
|
|
193
|
+
timeout: HTTP request timeout in seconds.
|
|
194
|
+
retry_config: Retry behavior configuration. Pass ``None`` for defaults
|
|
195
|
+
(3 retries, exponential backoff).
|
|
196
|
+
circuit_breaker_config: Circuit breaker configuration. Pass ``None``
|
|
197
|
+
for defaults (5-failure threshold, 60 s recovery).
|
|
198
|
+
user_agent: Custom ``User-Agent`` header value. Defaults to
|
|
199
|
+
``hyperping-python/0.1.0``.
|
|
200
|
+
"""
|
|
201
|
+
self._api_key = SecretStr(api_key) if isinstance(api_key, str) else api_key
|
|
202
|
+
self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
|
|
203
|
+
self.timeout = timeout
|
|
204
|
+
self.retry_config = retry_config or DEFAULT_RETRY_CONFIG
|
|
205
|
+
self._circuit_breaker = CircuitBreaker(circuit_breaker_config)
|
|
206
|
+
|
|
207
|
+
self._client = httpx.Client(
|
|
208
|
+
base_url=self.base_url,
|
|
209
|
+
headers={
|
|
210
|
+
"Authorization": f"Bearer {self._api_key.get_secret_value()}",
|
|
211
|
+
"Content-Type": "application/json",
|
|
212
|
+
"Accept": "application/json",
|
|
213
|
+
"User-Agent": user_agent or _DEFAULT_USER_AGENT,
|
|
214
|
+
},
|
|
215
|
+
timeout=self.timeout,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def __repr__(self) -> str:
|
|
219
|
+
return f"HyperpingClient(base_url={self.base_url!r})"
|
|
220
|
+
|
|
221
|
+
def close(self) -> None:
|
|
222
|
+
"""Close the HTTP client."""
|
|
223
|
+
self._client.close()
|
|
224
|
+
|
|
225
|
+
def __enter__(self) -> "HyperpingClient":
|
|
226
|
+
return self
|
|
227
|
+
|
|
228
|
+
def __exit__(self, *args: Any) -> None:
|
|
229
|
+
self.close()
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def circuit_breaker(self) -> CircuitBreaker:
|
|
233
|
+
"""Access the circuit breaker state (for monitoring)."""
|
|
234
|
+
return self._circuit_breaker
|
|
235
|
+
|
|
236
|
+
def _handle_response_error(self, response: httpx.Response) -> None:
|
|
237
|
+
"""Map HTTP errors to typed exceptions.
|
|
238
|
+
|
|
239
|
+
Extracts ``x-request-id`` from response headers when present and
|
|
240
|
+
attaches it to the raised exception for diagnostic context.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
response: The HTTP response with a 4xx or 5xx status code.
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
HyperpingAuthError: On 401 or 403.
|
|
247
|
+
HyperpingNotFoundError: On 404.
|
|
248
|
+
HyperpingRateLimitError: On 429.
|
|
249
|
+
HyperpingValidationError: On 400 or 422.
|
|
250
|
+
HyperpingAPIError: On all other error status codes.
|
|
251
|
+
"""
|
|
252
|
+
status = response.status_code
|
|
253
|
+
request_id = response.headers.get("x-request-id")
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
body = response.json()
|
|
257
|
+
except Exception:
|
|
258
|
+
body = {"error": response.text or "Unknown error"}
|
|
259
|
+
|
|
260
|
+
error_msg = body.get("error") or body.get("message") or f"HTTP {status}"
|
|
261
|
+
|
|
262
|
+
if status == 401 or status == 403:
|
|
263
|
+
raise HyperpingAuthError(
|
|
264
|
+
message=f"Authentication failed: {error_msg}",
|
|
265
|
+
status_code=status,
|
|
266
|
+
response_body=body,
|
|
267
|
+
request_id=request_id,
|
|
268
|
+
)
|
|
269
|
+
elif status == 404:
|
|
270
|
+
raise HyperpingNotFoundError(
|
|
271
|
+
message=f"Resource not found: {error_msg}",
|
|
272
|
+
status_code=status,
|
|
273
|
+
response_body=body,
|
|
274
|
+
request_id=request_id,
|
|
275
|
+
)
|
|
276
|
+
elif status == 429:
|
|
277
|
+
retry_after = response.headers.get("Retry-After")
|
|
278
|
+
retry_after_seconds: int | None = None
|
|
279
|
+
if retry_after:
|
|
280
|
+
try:
|
|
281
|
+
retry_after_seconds = int(retry_after)
|
|
282
|
+
except ValueError:
|
|
283
|
+
retry_after_seconds = None
|
|
284
|
+
raise HyperpingRateLimitError(
|
|
285
|
+
message=f"Rate limit exceeded: {error_msg}",
|
|
286
|
+
status_code=status,
|
|
287
|
+
response_body=body,
|
|
288
|
+
retry_after=retry_after_seconds,
|
|
289
|
+
request_id=request_id,
|
|
290
|
+
)
|
|
291
|
+
elif status == 400 or status == 422:
|
|
292
|
+
raise HyperpingValidationError(
|
|
293
|
+
message=f"Validation error: {error_msg}",
|
|
294
|
+
status_code=status,
|
|
295
|
+
response_body=body,
|
|
296
|
+
validation_errors=body.get("details") or body.get("errors"),
|
|
297
|
+
request_id=request_id,
|
|
298
|
+
)
|
|
299
|
+
else:
|
|
300
|
+
raise HyperpingAPIError(
|
|
301
|
+
message=f"API error: {error_msg}",
|
|
302
|
+
status_code=status,
|
|
303
|
+
response_body=body,
|
|
304
|
+
request_id=request_id,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def _execute_single_attempt(
|
|
308
|
+
self,
|
|
309
|
+
method: str,
|
|
310
|
+
path: str,
|
|
311
|
+
json: dict[str, Any] | None = None,
|
|
312
|
+
params: dict[str, Any] | None = None,
|
|
313
|
+
) -> dict[str, Any] | httpx.Response:
|
|
314
|
+
"""Execute a single HTTP request attempt.
|
|
315
|
+
|
|
316
|
+
Returns the parsed response dict on success, or the raw Response
|
|
317
|
+
object when the status code indicates a retryable/non-retryable error
|
|
318
|
+
(caller decides whether to retry).
|
|
319
|
+
|
|
320
|
+
Raises:
|
|
321
|
+
httpx.TimeoutException: On request timeout
|
|
322
|
+
httpx.RequestError: On connection/transport errors
|
|
323
|
+
"""
|
|
324
|
+
logger.debug(
|
|
325
|
+
f"API request: {method} {path} (attempt)",
|
|
326
|
+
extra={"json": json, "params": params},
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
response = self._client.request(method=method, url=path, json=json, params=params)
|
|
330
|
+
|
|
331
|
+
if response.status_code >= 400:
|
|
332
|
+
return response
|
|
333
|
+
|
|
334
|
+
# Success
|
|
335
|
+
self._circuit_breaker.record_success()
|
|
336
|
+
if response.status_code == 204:
|
|
337
|
+
return {}
|
|
338
|
+
return response.json() # type: ignore[no-any-return] # list endpoints return arrays; callers use isinstance checks
|
|
339
|
+
|
|
340
|
+
def _request(
|
|
341
|
+
self,
|
|
342
|
+
method: str,
|
|
343
|
+
path: str,
|
|
344
|
+
json: dict[str, Any] | None = None,
|
|
345
|
+
params: dict[str, Any] | None = None,
|
|
346
|
+
) -> dict[str, Any]:
|
|
347
|
+
"""Make an HTTP request with retry logic.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
method: HTTP method (GET, POST, PUT, DELETE)
|
|
351
|
+
path: API path (e.g., Endpoint.MONITORS)
|
|
352
|
+
json: Request body as dict
|
|
353
|
+
params: Query parameters
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Response body as dict
|
|
357
|
+
|
|
358
|
+
Raises:
|
|
359
|
+
HyperpingAPIError: On API errors after retries exhausted
|
|
360
|
+
"""
|
|
361
|
+
if not self._circuit_breaker.call_allowed():
|
|
362
|
+
raise HyperpingAPIError(
|
|
363
|
+
f"Circuit breaker OPEN — API calls suspended. "
|
|
364
|
+
f"Consecutive failures: {self._circuit_breaker.failure_count}. "
|
|
365
|
+
f"Will retry after {self.retry_config.initial_delay}s."
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
last_exception: Exception | None = None
|
|
369
|
+
delay = self.retry_config.initial_delay
|
|
370
|
+
max_attempts = self.retry_config.max_retries + 1
|
|
371
|
+
|
|
372
|
+
for attempt in range(max_attempts):
|
|
373
|
+
try:
|
|
374
|
+
result = self._execute_single_attempt(method, path, json, params)
|
|
375
|
+
|
|
376
|
+
if not isinstance(result, httpx.Response):
|
|
377
|
+
return result
|
|
378
|
+
|
|
379
|
+
response = result
|
|
380
|
+
if (
|
|
381
|
+
response.status_code in self.retry_config.retry_on_status
|
|
382
|
+
and attempt < self.retry_config.max_retries
|
|
383
|
+
):
|
|
384
|
+
if response.status_code == 429:
|
|
385
|
+
retry_after = response.headers.get("Retry-After")
|
|
386
|
+
if retry_after:
|
|
387
|
+
delay = min(float(retry_after), _RETRY_AFTER_MAX)
|
|
388
|
+
sleep_time = delay
|
|
389
|
+
if response.status_code != 429:
|
|
390
|
+
sleep_time = delay + random.uniform(0, delay * 0.25)
|
|
391
|
+
logger.warning(
|
|
392
|
+
f"Retrying after {sleep_time:.2f}s due to {response.status_code} "
|
|
393
|
+
f"(attempt {attempt + 1}/{max_attempts})"
|
|
394
|
+
)
|
|
395
|
+
time.sleep(sleep_time)
|
|
396
|
+
delay = min(
|
|
397
|
+
delay * self.retry_config.backoff_factor,
|
|
398
|
+
self.retry_config.max_delay,
|
|
399
|
+
)
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
# Only trip circuit breaker on server errors, not client errors
|
|
403
|
+
if response.status_code >= 500:
|
|
404
|
+
self._circuit_breaker.record_failure()
|
|
405
|
+
self._handle_response_error(response)
|
|
406
|
+
|
|
407
|
+
except (httpx.TimeoutException, httpx.RequestError) as e:
|
|
408
|
+
last_exception = e
|
|
409
|
+
if attempt < self.retry_config.max_retries:
|
|
410
|
+
label = "timeout" if isinstance(e, httpx.TimeoutException) else str(e)
|
|
411
|
+
sleep_time = delay + random.uniform(0, delay * 0.25)
|
|
412
|
+
logger.warning(
|
|
413
|
+
f"Request {label}, retrying after {sleep_time:.2f}s "
|
|
414
|
+
f"(attempt {attempt + 1}/{max_attempts})"
|
|
415
|
+
)
|
|
416
|
+
time.sleep(sleep_time)
|
|
417
|
+
delay = min(
|
|
418
|
+
delay * self.retry_config.backoff_factor,
|
|
419
|
+
self.retry_config.max_delay,
|
|
420
|
+
)
|
|
421
|
+
continue
|
|
422
|
+
self._circuit_breaker.record_failure()
|
|
423
|
+
if isinstance(e, httpx.TimeoutException):
|
|
424
|
+
raise HyperpingAPIError(
|
|
425
|
+
f"Request timeout after {max_attempts} attempts"
|
|
426
|
+
) from e
|
|
427
|
+
raise HyperpingAPIError(f"Request failed: {e}") from e
|
|
428
|
+
|
|
429
|
+
# Should not reach here, but just in case
|
|
430
|
+
raise HyperpingAPIError( # pragma: no cover
|
|
431
|
+
"Request failed after all retries"
|
|
432
|
+
) from last_exception
|
|
433
|
+
|
|
434
|
+
# ==================== Health Check ====================
|
|
435
|
+
|
|
436
|
+
def ping(self) -> bool:
|
|
437
|
+
"""Test API connectivity and authentication.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
True if connection successful
|
|
441
|
+
|
|
442
|
+
Raises:
|
|
443
|
+
HyperpingAuthError: If authentication fails
|
|
444
|
+
HyperpingAPIError: If connection fails
|
|
445
|
+
"""
|
|
446
|
+
try:
|
|
447
|
+
self.list_monitors()
|
|
448
|
+
return True
|
|
449
|
+
except HyperpingAuthError:
|
|
450
|
+
raise
|
|
451
|
+
except (HyperpingAPIError, httpx.RequestError, httpx.TimeoutException) as e:
|
|
452
|
+
raise HyperpingAPIError(f"API connectivity test failed: {e}") from e
|
hyperping/endpoints.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""API endpoint definitions - single source of truth for all Hyperping API paths.
|
|
2
|
+
|
|
3
|
+
This module uses frozen dataclasses and StrEnum for type-safe, immutable endpoint
|
|
4
|
+
configuration following modern Python best practices.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from hyperping.endpoints import API_BASE, Endpoint
|
|
8
|
+
|
|
9
|
+
# Type-safe endpoint access with IDE autocompletion
|
|
10
|
+
url = f"{API_BASE}{Endpoint.MONITORS}" # https://api.hyperping.io/v1/monitors
|
|
11
|
+
url = f"{API_BASE}{Endpoint.INCIDENTS}" # https://api.hyperping.io/v3/incidents
|
|
12
|
+
url = f"{API_BASE}{Endpoint.MONITORS}/mon_123" # https://api.hyperping.io/v1/monitors/mon_123
|
|
13
|
+
|
|
14
|
+
# Access metadata
|
|
15
|
+
Endpoint.MONITORS.version # "v1"
|
|
16
|
+
Endpoint.MONITORS.resource # "monitors"
|
|
17
|
+
Endpoint.INCIDENTS.version # "v3"
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from enum import StrEnum
|
|
22
|
+
from typing import Final
|
|
23
|
+
|
|
24
|
+
# =============================================================================
|
|
25
|
+
# API Base URL - Single Source of Truth
|
|
26
|
+
# =============================================================================
|
|
27
|
+
|
|
28
|
+
API_BASE: Final[str] = "https://api.hyperping.io"
|
|
29
|
+
"""Base URL for all Hyperping API requests. Never includes trailing slash."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# =============================================================================
|
|
33
|
+
# API Versions
|
|
34
|
+
# =============================================================================
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class APIVersion(StrEnum):
|
|
38
|
+
"""API versions used by Hyperping.
|
|
39
|
+
|
|
40
|
+
Different resources use different API versions. This enum documents
|
|
41
|
+
the available versions and provides type safety.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
V1 = "v1"
|
|
45
|
+
"""Version 1 - Used by monitors and maintenance endpoints."""
|
|
46
|
+
|
|
47
|
+
V2 = "v2"
|
|
48
|
+
"""Version 2 - Used by reporting endpoints."""
|
|
49
|
+
|
|
50
|
+
V3 = "v3"
|
|
51
|
+
"""Version 3 - Used by incidents endpoints."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# =============================================================================
|
|
55
|
+
# Endpoint Configuration
|
|
56
|
+
# =============================================================================
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class EndpointConfig:
|
|
61
|
+
"""Immutable configuration for an API endpoint.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
version: API version (e.g., "v1", "v2", "v3")
|
|
65
|
+
resource: Resource name/path (e.g., "monitors", "incidents")
|
|
66
|
+
description: Human-readable description for documentation
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
>>> config = EndpointConfig(version=APIVersion.V1, resource="monitors")
|
|
70
|
+
>>> str(config)
|
|
71
|
+
'/v1/monitors'
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
version: APIVersion
|
|
75
|
+
resource: str
|
|
76
|
+
description: str = ""
|
|
77
|
+
|
|
78
|
+
def __str__(self) -> str:
|
|
79
|
+
"""Return the full path for this endpoint."""
|
|
80
|
+
return f"/{self.version}/{self.resource}"
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def path(self) -> str:
|
|
84
|
+
"""Alias for string representation."""
|
|
85
|
+
return str(self)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# =============================================================================
|
|
89
|
+
# Endpoint Registry - Type-Safe Endpoint Access
|
|
90
|
+
# =============================================================================
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Endpoint(StrEnum):
|
|
94
|
+
"""Type-safe endpoint paths for the Hyperping API.
|
|
95
|
+
|
|
96
|
+
This enum provides:
|
|
97
|
+
- IDE autocompletion for endpoint names
|
|
98
|
+
- Type safety (typos cause AttributeError, not silent bugs)
|
|
99
|
+
- Immutability (endpoints cannot be modified at runtime)
|
|
100
|
+
- Self-documenting code
|
|
101
|
+
|
|
102
|
+
Usage:
|
|
103
|
+
# Direct string usage (StrEnum inherits from str)
|
|
104
|
+
url = f"{API_BASE}{Endpoint.MONITORS}"
|
|
105
|
+
url = f"{API_BASE}{Endpoint.MONITORS}/{monitor_id}"
|
|
106
|
+
|
|
107
|
+
# Access version info via ENDPOINTS dict
|
|
108
|
+
version = ENDPOINTS[Endpoint.MONITORS].version
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
# Monitors - v1
|
|
112
|
+
MONITORS = "/v1/monitors"
|
|
113
|
+
"""Monitor CRUD operations. Version: v1"""
|
|
114
|
+
|
|
115
|
+
# Maintenance - v1
|
|
116
|
+
MAINTENANCE = "/v1/maintenance-windows"
|
|
117
|
+
"""Maintenance window operations. Version: v1"""
|
|
118
|
+
|
|
119
|
+
# Incidents - v3
|
|
120
|
+
INCIDENTS = "/v3/incidents"
|
|
121
|
+
"""Incident management operations. Version: v3"""
|
|
122
|
+
|
|
123
|
+
# Reports - v2
|
|
124
|
+
REPORTS = "/v2/reporting/monitor-reports"
|
|
125
|
+
"""Uptime and performance reporting. Version: v2"""
|
|
126
|
+
|
|
127
|
+
# Outages - v2
|
|
128
|
+
OUTAGES = "/v2/outages"
|
|
129
|
+
"""Auto-detected outage management. Version: v2"""
|
|
130
|
+
|
|
131
|
+
# Status Pages - v2
|
|
132
|
+
STATUSPAGES = "/v2/statuspages"
|
|
133
|
+
"""Status page management. Version: v2"""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Detailed endpoint metadata for programmatic access
|
|
137
|
+
ENDPOINTS: Final[dict[Endpoint, EndpointConfig]] = {
|
|
138
|
+
Endpoint.MONITORS: EndpointConfig(
|
|
139
|
+
version=APIVersion.V1,
|
|
140
|
+
resource="monitors",
|
|
141
|
+
description="Monitor CRUD operations - create, read, update, delete monitors",
|
|
142
|
+
),
|
|
143
|
+
Endpoint.MAINTENANCE: EndpointConfig(
|
|
144
|
+
version=APIVersion.V1,
|
|
145
|
+
resource="maintenance-windows",
|
|
146
|
+
description="Maintenance window scheduling and management",
|
|
147
|
+
),
|
|
148
|
+
Endpoint.INCIDENTS: EndpointConfig(
|
|
149
|
+
version=APIVersion.V3,
|
|
150
|
+
resource="incidents",
|
|
151
|
+
description="Incident creation, updates, and resolution",
|
|
152
|
+
),
|
|
153
|
+
Endpoint.REPORTS: EndpointConfig(
|
|
154
|
+
version=APIVersion.V2,
|
|
155
|
+
resource="reporting/monitor-reports",
|
|
156
|
+
description="Uptime percentages and performance metrics",
|
|
157
|
+
),
|
|
158
|
+
Endpoint.OUTAGES: EndpointConfig(
|
|
159
|
+
version=APIVersion.V2,
|
|
160
|
+
resource="outages",
|
|
161
|
+
description="Auto-detected outage management (list, ack, resolve, escalate)",
|
|
162
|
+
),
|
|
163
|
+
Endpoint.STATUSPAGES: EndpointConfig(
|
|
164
|
+
version=APIVersion.V2,
|
|
165
|
+
resource="statuspages",
|
|
166
|
+
description="Status page CRUD, subscribers management",
|
|
167
|
+
),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# =============================================================================
|
|
172
|
+
# Backward Compatibility - Deprecation Layer
|
|
173
|
+
# =============================================================================
|
|
174
|
+
|
|
175
|
+
# These maintain backward compatibility with existing code.
|
|
176
|
+
# TODO: Deprecate in future version and migrate all usage to Endpoint enum.
|
|
177
|
+
|
|
178
|
+
HYPERPING_API_BASE: Final[str] = API_BASE
|
|
179
|
+
"""Deprecated: Use API_BASE instead."""
|
|
180
|
+
|
|
181
|
+
API_PATHS: Final[dict[str, str]] = {
|
|
182
|
+
"monitors": Endpoint.MONITORS.value,
|
|
183
|
+
"maintenance": Endpoint.MAINTENANCE.value,
|
|
184
|
+
"incidents": Endpoint.INCIDENTS.value,
|
|
185
|
+
"reports": Endpoint.REPORTS.value,
|
|
186
|
+
"outages": Endpoint.OUTAGES.value,
|
|
187
|
+
"statuspages": Endpoint.STATUSPAGES.value,
|
|
188
|
+
}
|
|
189
|
+
"""Deprecated: Use Endpoint enum instead for type safety."""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# =============================================================================
|
|
193
|
+
# Utility Functions
|
|
194
|
+
# =============================================================================
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_endpoint_url(endpoint: Endpoint, resource_id: str | None = None) -> str:
|
|
198
|
+
"""Build a full URL for an API endpoint.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
endpoint: The API endpoint
|
|
202
|
+
resource_id: Optional resource ID for single-resource operations
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Full URL string
|
|
206
|
+
|
|
207
|
+
Example:
|
|
208
|
+
>>> get_endpoint_url(Endpoint.MONITORS)
|
|
209
|
+
'https://api.hyperping.io/v1/monitors'
|
|
210
|
+
>>> get_endpoint_url(Endpoint.MONITORS, "mon_123")
|
|
211
|
+
'https://api.hyperping.io/v1/monitors/mon_123'
|
|
212
|
+
"""
|
|
213
|
+
if resource_id:
|
|
214
|
+
return f"{API_BASE}{endpoint}/{resource_id}"
|
|
215
|
+
return f"{API_BASE}{endpoint}"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def get_version_for_endpoint(endpoint: Endpoint) -> APIVersion:
|
|
219
|
+
"""Get the API version used by an endpoint.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
endpoint: The API endpoint
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
The API version enum value
|
|
226
|
+
|
|
227
|
+
Example:
|
|
228
|
+
>>> get_version_for_endpoint(Endpoint.INCIDENTS)
|
|
229
|
+
<APIVersion.V3: 'v3'>
|
|
230
|
+
"""
|
|
231
|
+
return ENDPOINTS[endpoint].version
|