hookbridge 1.0.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.
- hookbridge/__init__.py +72 -0
- hookbridge/client.py +820 -0
- hookbridge/errors.py +112 -0
- hookbridge/py.typed +0 -0
- hookbridge/types.py +202 -0
- hookbridge-1.0.0.dist-info/METADATA +241 -0
- hookbridge-1.0.0.dist-info/RECORD +8 -0
- hookbridge-1.0.0.dist-info/WHEEL +4 -0
hookbridge/client.py
ADDED
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HookBridge SDK Client
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Optional, Union
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from hookbridge.errors import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
HookBridgeError,
|
|
15
|
+
IdempotencyError,
|
|
16
|
+
NetworkError,
|
|
17
|
+
NotFoundError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
ReplayLimitError,
|
|
20
|
+
TimeoutError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
)
|
|
23
|
+
from hookbridge.types import (
|
|
24
|
+
APIKeyInfo,
|
|
25
|
+
APIKeyMode,
|
|
26
|
+
CreateAPIKeyResponse,
|
|
27
|
+
DLQMessagesResponse,
|
|
28
|
+
LogsResponse,
|
|
29
|
+
Message,
|
|
30
|
+
MessageStatus,
|
|
31
|
+
MessageSummary,
|
|
32
|
+
Metrics,
|
|
33
|
+
MetricsWindow,
|
|
34
|
+
SendWebhookResponse,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
DEFAULT_BASE_URL = os.environ.get("HOOKBRIDGE_BASE_URL", "https://api.hookbridge.io")
|
|
38
|
+
DEFAULT_TIMEOUT = 30.0
|
|
39
|
+
DEFAULT_RETRIES = 3
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class HookBridge:
|
|
43
|
+
"""
|
|
44
|
+
HookBridge API client (synchronous).
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
>>> from hookbridge import HookBridge
|
|
48
|
+
>>>
|
|
49
|
+
>>> client = HookBridge(api_key="hb_live_xxxxxxxxxxxxxxxxxxxx")
|
|
50
|
+
>>>
|
|
51
|
+
>>> result = client.send(
|
|
52
|
+
... endpoint="https://customer.app/webhooks",
|
|
53
|
+
... payload={"event": "order.created", "order_id": "12345"}
|
|
54
|
+
... )
|
|
55
|
+
>>> print(result.message_id)
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
api_key: str,
|
|
61
|
+
*,
|
|
62
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
63
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
64
|
+
retries: int = DEFAULT_RETRIES,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Create a new HookBridge client.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
api_key: Your HookBridge API key (starts with hb_live_ or hb_test_)
|
|
71
|
+
base_url: Base URL for the API (default: https://api.hookbridge.io)
|
|
72
|
+
timeout: Request timeout in seconds (default: 30)
|
|
73
|
+
retries: Number of retries for failed requests (default: 3)
|
|
74
|
+
"""
|
|
75
|
+
if not api_key:
|
|
76
|
+
raise ValidationError("API key is required")
|
|
77
|
+
|
|
78
|
+
self._api_key = api_key
|
|
79
|
+
self._base_url = base_url.rstrip("/")
|
|
80
|
+
self._timeout = timeout
|
|
81
|
+
self._retries = retries
|
|
82
|
+
self._client = httpx.Client(
|
|
83
|
+
base_url=self._base_url,
|
|
84
|
+
timeout=timeout,
|
|
85
|
+
headers={
|
|
86
|
+
"Authorization": f"Bearer {api_key}",
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
"Accept": "application/json",
|
|
89
|
+
"User-Agent": "hookbridge-python/1.0.0",
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def __enter__(self) -> "HookBridge":
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def __exit__(self, *args: Any) -> None:
|
|
97
|
+
self.close()
|
|
98
|
+
|
|
99
|
+
def close(self) -> None:
|
|
100
|
+
"""Close the HTTP client."""
|
|
101
|
+
self._client.close()
|
|
102
|
+
|
|
103
|
+
def send(
|
|
104
|
+
self,
|
|
105
|
+
endpoint: str,
|
|
106
|
+
payload: dict[str, Any],
|
|
107
|
+
*,
|
|
108
|
+
headers: Optional[dict[str, str]] = None,
|
|
109
|
+
idempotency_key: Optional[str] = None,
|
|
110
|
+
) -> SendWebhookResponse:
|
|
111
|
+
"""
|
|
112
|
+
Send a webhook for delivery.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
endpoint: HTTPS URL of the webhook endpoint
|
|
116
|
+
payload: JSON payload to send (max 1 MB)
|
|
117
|
+
headers: Optional custom headers to include
|
|
118
|
+
idempotency_key: Optional key to prevent duplicate sends
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
SendWebhookResponse with message_id and status
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
>>> result = client.send(
|
|
125
|
+
... endpoint="https://customer.app/webhooks",
|
|
126
|
+
... payload={"event": "order.created"},
|
|
127
|
+
... idempotency_key="order-123-created"
|
|
128
|
+
... )
|
|
129
|
+
"""
|
|
130
|
+
body = {
|
|
131
|
+
"endpoint": endpoint,
|
|
132
|
+
"payload": payload,
|
|
133
|
+
}
|
|
134
|
+
if headers:
|
|
135
|
+
body["headers"] = headers
|
|
136
|
+
if idempotency_key:
|
|
137
|
+
body["idempotency_key"] = idempotency_key
|
|
138
|
+
|
|
139
|
+
response = self._request("POST", "/v1/webhooks/send", json=body)
|
|
140
|
+
data = response["data"]
|
|
141
|
+
|
|
142
|
+
return SendWebhookResponse(
|
|
143
|
+
message_id=data["message_id"],
|
|
144
|
+
status=data["status"],
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def get_message(self, message_id: str) -> Message:
|
|
148
|
+
"""
|
|
149
|
+
Get details for a specific message.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
message_id: The message ID to retrieve
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Message with full details
|
|
156
|
+
"""
|
|
157
|
+
response = self._request("GET", f"/v1/messages/{message_id}")
|
|
158
|
+
return self._parse_message(response["data"])
|
|
159
|
+
|
|
160
|
+
def replay(self, message_id: str) -> None:
|
|
161
|
+
"""
|
|
162
|
+
Replay a message for redelivery.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
message_id: The message ID to replay
|
|
166
|
+
"""
|
|
167
|
+
self._request("POST", f"/v1/messages/{message_id}/replay")
|
|
168
|
+
|
|
169
|
+
def cancel_retry(self, message_id: str) -> None:
|
|
170
|
+
"""
|
|
171
|
+
Cancel a pending retry.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
message_id: The message ID to cancel
|
|
175
|
+
"""
|
|
176
|
+
self._request("POST", f"/v1/messages/{message_id}/cancel")
|
|
177
|
+
|
|
178
|
+
def retry_now(self, message_id: str) -> None:
|
|
179
|
+
"""
|
|
180
|
+
Trigger an immediate retry for a pending message.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
message_id: The message ID to retry
|
|
184
|
+
"""
|
|
185
|
+
self._request("POST", f"/v1/messages/{message_id}/retry-now")
|
|
186
|
+
|
|
187
|
+
def get_logs(
|
|
188
|
+
self,
|
|
189
|
+
*,
|
|
190
|
+
status: Optional[MessageStatus] = None,
|
|
191
|
+
start_time: Optional[Union[datetime, str]] = None,
|
|
192
|
+
end_time: Optional[Union[datetime, str]] = None,
|
|
193
|
+
limit: Optional[int] = None,
|
|
194
|
+
cursor: Optional[str] = None,
|
|
195
|
+
) -> LogsResponse:
|
|
196
|
+
"""
|
|
197
|
+
Query delivery logs with optional filters.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
status: Filter by message status
|
|
201
|
+
start_time: Filter messages created after this time
|
|
202
|
+
end_time: Filter messages created before this time
|
|
203
|
+
limit: Maximum results to return (1-500, default 50)
|
|
204
|
+
cursor: Pagination cursor from previous response
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
LogsResponse with messages, has_more, and next_cursor
|
|
208
|
+
"""
|
|
209
|
+
params: dict[str, str] = {}
|
|
210
|
+
if status:
|
|
211
|
+
params["status"] = status
|
|
212
|
+
if start_time:
|
|
213
|
+
params["start_time"] = (
|
|
214
|
+
start_time.isoformat() if isinstance(start_time, datetime) else start_time
|
|
215
|
+
)
|
|
216
|
+
if end_time:
|
|
217
|
+
params["end_time"] = (
|
|
218
|
+
end_time.isoformat() if isinstance(end_time, datetime) else end_time
|
|
219
|
+
)
|
|
220
|
+
if limit:
|
|
221
|
+
params["limit"] = str(limit)
|
|
222
|
+
if cursor:
|
|
223
|
+
params["cursor"] = cursor
|
|
224
|
+
|
|
225
|
+
path = "/v1/logs"
|
|
226
|
+
if params:
|
|
227
|
+
path = f"{path}?{urlencode(params)}"
|
|
228
|
+
|
|
229
|
+
response = self._request("GET", path)
|
|
230
|
+
return LogsResponse(
|
|
231
|
+
messages=[self._parse_message_summary(m) for m in response["data"]],
|
|
232
|
+
has_more=response["meta"]["has_more"],
|
|
233
|
+
next_cursor=response["meta"].get("next_cursor"),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def get_metrics(self, window: MetricsWindow = "24h") -> Metrics:
|
|
237
|
+
"""
|
|
238
|
+
Get aggregated delivery metrics.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
window: Time window for aggregation (1h, 24h, 7d, 30d)
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Metrics with counts and success rate
|
|
245
|
+
"""
|
|
246
|
+
response = self._request("GET", f"/v1/metrics?window={window}")
|
|
247
|
+
data = response["data"]
|
|
248
|
+
return Metrics(
|
|
249
|
+
window=data["window"],
|
|
250
|
+
total_messages=data["total_messages"],
|
|
251
|
+
succeeded=data["succeeded"],
|
|
252
|
+
failed=data["failed"],
|
|
253
|
+
retries=data["retries"],
|
|
254
|
+
success_rate=data["success_rate"],
|
|
255
|
+
avg_latency_ms=data["avg_latency_ms"],
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def get_dlq_messages(
|
|
259
|
+
self,
|
|
260
|
+
*,
|
|
261
|
+
limit: Optional[int] = None,
|
|
262
|
+
cursor: Optional[str] = None,
|
|
263
|
+
) -> DLQMessagesResponse:
|
|
264
|
+
"""
|
|
265
|
+
List messages in the Dead Letter Queue.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
limit: Maximum results to return (1-1000, default 100)
|
|
269
|
+
cursor: Pagination cursor from previous response
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
DLQMessagesResponse with failed messages
|
|
273
|
+
"""
|
|
274
|
+
params: dict[str, str] = {}
|
|
275
|
+
if limit:
|
|
276
|
+
params["limit"] = str(limit)
|
|
277
|
+
if cursor:
|
|
278
|
+
params["cursor"] = cursor
|
|
279
|
+
|
|
280
|
+
path = "/v1/dlq/messages"
|
|
281
|
+
if params:
|
|
282
|
+
path = f"{path}?{urlencode(params)}"
|
|
283
|
+
|
|
284
|
+
response = self._request("GET", path)
|
|
285
|
+
data = response["data"]
|
|
286
|
+
return DLQMessagesResponse(
|
|
287
|
+
messages=[self._parse_message_summary(m) for m in (data.get("messages") or [])],
|
|
288
|
+
has_more=data["has_more"],
|
|
289
|
+
next_cursor=data.get("next_cursor"),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def replay_from_dlq(self, message_id: str) -> None:
|
|
293
|
+
"""
|
|
294
|
+
Replay a message from the Dead Letter Queue.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
message_id: The message ID to replay
|
|
298
|
+
"""
|
|
299
|
+
self._request("POST", f"/v1/dlq/replay/{message_id}")
|
|
300
|
+
|
|
301
|
+
def list_api_keys(self) -> list[APIKeyInfo]:
|
|
302
|
+
"""
|
|
303
|
+
List all API keys for the project.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
List of APIKeyInfo
|
|
307
|
+
"""
|
|
308
|
+
response = self._request("GET", "/v1/api-keys")
|
|
309
|
+
return [
|
|
310
|
+
APIKeyInfo(
|
|
311
|
+
key_id=key["key_id"],
|
|
312
|
+
label=key["label"],
|
|
313
|
+
prefix=key["prefix"],
|
|
314
|
+
created_at=self._parse_datetime(key["created_at"]),
|
|
315
|
+
last_used_at=(
|
|
316
|
+
self._parse_datetime(key["last_used_at"]) if key.get("last_used_at") else None
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
for key in response["data"]
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
def create_api_key(
|
|
323
|
+
self,
|
|
324
|
+
mode: APIKeyMode,
|
|
325
|
+
*,
|
|
326
|
+
label: Optional[str] = None,
|
|
327
|
+
) -> CreateAPIKeyResponse:
|
|
328
|
+
"""
|
|
329
|
+
Create a new API key.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
mode: Key mode ('live' or 'test')
|
|
333
|
+
label: Optional human-readable label
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
CreateAPIKeyResponse with the new key (shown only once!)
|
|
337
|
+
"""
|
|
338
|
+
body: dict[str, Any] = {"mode": mode}
|
|
339
|
+
if label:
|
|
340
|
+
body["label"] = label
|
|
341
|
+
|
|
342
|
+
response = self._request("POST", "/v1/api-keys", json=body)
|
|
343
|
+
data = response["data"]
|
|
344
|
+
return CreateAPIKeyResponse(
|
|
345
|
+
key_id=data["key_id"],
|
|
346
|
+
key=data["key"],
|
|
347
|
+
prefix=data["prefix"],
|
|
348
|
+
label=data["label"],
|
|
349
|
+
created_at=self._parse_datetime(data["created_at"]),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def delete_api_key(self, key_id: str) -> None:
|
|
353
|
+
"""
|
|
354
|
+
Delete an API key.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
key_id: The API key ID to delete
|
|
358
|
+
"""
|
|
359
|
+
self._request("DELETE", f"/v1/api-keys/{key_id}")
|
|
360
|
+
|
|
361
|
+
def _request(
|
|
362
|
+
self,
|
|
363
|
+
method: str,
|
|
364
|
+
path: str,
|
|
365
|
+
*,
|
|
366
|
+
json: Optional[dict[str, Any]] = None,
|
|
367
|
+
) -> dict[str, Any]:
|
|
368
|
+
"""Make an HTTP request with retries."""
|
|
369
|
+
last_error: Optional[Exception] = None
|
|
370
|
+
|
|
371
|
+
for attempt in range(self._retries + 1):
|
|
372
|
+
try:
|
|
373
|
+
response = self._client.request(method, path, json=json)
|
|
374
|
+
|
|
375
|
+
if not response.is_success:
|
|
376
|
+
self._handle_error_response(response)
|
|
377
|
+
|
|
378
|
+
return response.json() # type: ignore[no-any-return]
|
|
379
|
+
|
|
380
|
+
except HookBridgeError as e:
|
|
381
|
+
# Don't retry client errors (4xx)
|
|
382
|
+
if e.status_code and 400 <= e.status_code < 500:
|
|
383
|
+
raise
|
|
384
|
+
last_error = e
|
|
385
|
+
|
|
386
|
+
except httpx.TimeoutException:
|
|
387
|
+
last_error = TimeoutError()
|
|
388
|
+
|
|
389
|
+
except httpx.RequestError as e:
|
|
390
|
+
last_error = NetworkError(str(e))
|
|
391
|
+
|
|
392
|
+
# Wait before retrying
|
|
393
|
+
if attempt < self._retries:
|
|
394
|
+
import time
|
|
395
|
+
|
|
396
|
+
time.sleep(2**attempt * 0.1)
|
|
397
|
+
|
|
398
|
+
if last_error:
|
|
399
|
+
raise last_error
|
|
400
|
+
raise NetworkError("Request failed")
|
|
401
|
+
|
|
402
|
+
def _handle_error_response(self, response: httpx.Response) -> None:
|
|
403
|
+
"""Handle error responses from the API."""
|
|
404
|
+
try:
|
|
405
|
+
data = response.json()
|
|
406
|
+
code = data.get("error", {}).get("code", "UNKNOWN_ERROR")
|
|
407
|
+
message = data.get("error", {}).get("message", f"HTTP {response.status_code}")
|
|
408
|
+
request_id = data.get("meta", {}).get("request_id")
|
|
409
|
+
except Exception:
|
|
410
|
+
code = "UNKNOWN_ERROR"
|
|
411
|
+
message = f"HTTP {response.status_code}"
|
|
412
|
+
request_id = None
|
|
413
|
+
|
|
414
|
+
if response.status_code == 401:
|
|
415
|
+
raise AuthenticationError(message, request_id)
|
|
416
|
+
elif response.status_code == 404:
|
|
417
|
+
raise NotFoundError(message, request_id)
|
|
418
|
+
elif response.status_code == 409:
|
|
419
|
+
if code == "IDEMPOTENCY_MISMATCH":
|
|
420
|
+
raise IdempotencyError(message, request_id)
|
|
421
|
+
raise HookBridgeError(message, code, request_id, response.status_code)
|
|
422
|
+
elif response.status_code == 429:
|
|
423
|
+
if code == "REPLAY_LIMIT_EXCEEDED":
|
|
424
|
+
raise ReplayLimitError(message, request_id)
|
|
425
|
+
retry_after = response.headers.get("Retry-After")
|
|
426
|
+
raise RateLimitError(
|
|
427
|
+
message, request_id, int(retry_after) if retry_after else None
|
|
428
|
+
)
|
|
429
|
+
else:
|
|
430
|
+
raise HookBridgeError(message, code, request_id, response.status_code)
|
|
431
|
+
|
|
432
|
+
def _parse_message(self, data: dict[str, Any]) -> Message:
|
|
433
|
+
"""Parse a message from API response."""
|
|
434
|
+
return Message(
|
|
435
|
+
id=data["id"],
|
|
436
|
+
project_id=data["project_id"],
|
|
437
|
+
endpoint_id=data["endpoint_id"],
|
|
438
|
+
status=data["status"],
|
|
439
|
+
attempt_count=data["attempt_count"],
|
|
440
|
+
replay_count=data["replay_count"],
|
|
441
|
+
content_type=data["content_type"],
|
|
442
|
+
size_bytes=data["size_bytes"],
|
|
443
|
+
payload_sha256=data["payload_sha256"],
|
|
444
|
+
created_at=self._parse_datetime(data["created_at"]),
|
|
445
|
+
updated_at=self._parse_datetime(data["updated_at"]),
|
|
446
|
+
idempotency_key=data.get("idempotency_key"),
|
|
447
|
+
next_attempt_at=(
|
|
448
|
+
self._parse_datetime(data["next_attempt_at"])
|
|
449
|
+
if data.get("next_attempt_at")
|
|
450
|
+
else None
|
|
451
|
+
),
|
|
452
|
+
last_error=data.get("last_error"),
|
|
453
|
+
response_status=data.get("response_status"),
|
|
454
|
+
response_latency_ms=data.get("response_latency_ms"),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
def _parse_message_summary(self, data: dict[str, Any]) -> MessageSummary:
|
|
458
|
+
"""Parse a message summary from API response."""
|
|
459
|
+
return MessageSummary(
|
|
460
|
+
message_id=data["message_id"],
|
|
461
|
+
endpoint=data["endpoint"],
|
|
462
|
+
status=data["status"],
|
|
463
|
+
attempt_count=data["attempt_count"],
|
|
464
|
+
created_at=self._parse_datetime(data["created_at"]),
|
|
465
|
+
delivered_at=(
|
|
466
|
+
self._parse_datetime(data["delivered_at"]) if data.get("delivered_at") else None
|
|
467
|
+
),
|
|
468
|
+
response_status=data.get("response_status"),
|
|
469
|
+
response_latency_ms=data.get("response_latency_ms"),
|
|
470
|
+
last_error=data.get("last_error"),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
@staticmethod
|
|
474
|
+
def _parse_datetime(value: str) -> datetime:
|
|
475
|
+
"""Parse an ISO 8601 datetime string."""
|
|
476
|
+
# Handle both with and without microseconds
|
|
477
|
+
if "." in value:
|
|
478
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
479
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
class AsyncHookBridge:
|
|
483
|
+
"""
|
|
484
|
+
HookBridge API client (asynchronous).
|
|
485
|
+
|
|
486
|
+
Example:
|
|
487
|
+
>>> from hookbridge import AsyncHookBridge
|
|
488
|
+
>>>
|
|
489
|
+
>>> async with AsyncHookBridge(api_key="hb_live_xxx") as client:
|
|
490
|
+
... result = await client.send(
|
|
491
|
+
... endpoint="https://customer.app/webhooks",
|
|
492
|
+
... payload={"event": "order.created"}
|
|
493
|
+
... )
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
def __init__(
|
|
497
|
+
self,
|
|
498
|
+
api_key: str,
|
|
499
|
+
*,
|
|
500
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
501
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
502
|
+
retries: int = DEFAULT_RETRIES,
|
|
503
|
+
) -> None:
|
|
504
|
+
"""
|
|
505
|
+
Create a new async HookBridge client.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
api_key: Your HookBridge API key
|
|
509
|
+
base_url: Base URL for the API
|
|
510
|
+
timeout: Request timeout in seconds
|
|
511
|
+
retries: Number of retries for failed requests
|
|
512
|
+
"""
|
|
513
|
+
if not api_key:
|
|
514
|
+
raise ValidationError("API key is required")
|
|
515
|
+
|
|
516
|
+
self._api_key = api_key
|
|
517
|
+
self._base_url = base_url.rstrip("/")
|
|
518
|
+
self._timeout = timeout
|
|
519
|
+
self._retries = retries
|
|
520
|
+
self._client = httpx.AsyncClient(
|
|
521
|
+
base_url=self._base_url,
|
|
522
|
+
timeout=timeout,
|
|
523
|
+
headers={
|
|
524
|
+
"Authorization": f"Bearer {api_key}",
|
|
525
|
+
"Content-Type": "application/json",
|
|
526
|
+
"Accept": "application/json",
|
|
527
|
+
"User-Agent": "hookbridge-python/1.0.0",
|
|
528
|
+
},
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
async def __aenter__(self) -> "AsyncHookBridge":
|
|
532
|
+
return self
|
|
533
|
+
|
|
534
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
535
|
+
await self.close()
|
|
536
|
+
|
|
537
|
+
async def close(self) -> None:
|
|
538
|
+
"""Close the HTTP client."""
|
|
539
|
+
await self._client.aclose()
|
|
540
|
+
|
|
541
|
+
async def send(
|
|
542
|
+
self,
|
|
543
|
+
endpoint: str,
|
|
544
|
+
payload: dict[str, Any],
|
|
545
|
+
*,
|
|
546
|
+
headers: Optional[dict[str, str]] = None,
|
|
547
|
+
idempotency_key: Optional[str] = None,
|
|
548
|
+
) -> SendWebhookResponse:
|
|
549
|
+
"""Send a webhook for delivery."""
|
|
550
|
+
body = {
|
|
551
|
+
"endpoint": endpoint,
|
|
552
|
+
"payload": payload,
|
|
553
|
+
}
|
|
554
|
+
if headers:
|
|
555
|
+
body["headers"] = headers
|
|
556
|
+
if idempotency_key:
|
|
557
|
+
body["idempotency_key"] = idempotency_key
|
|
558
|
+
|
|
559
|
+
response = await self._request("POST", "/v1/webhooks/send", json=body)
|
|
560
|
+
data = response["data"]
|
|
561
|
+
|
|
562
|
+
return SendWebhookResponse(
|
|
563
|
+
message_id=data["message_id"],
|
|
564
|
+
status=data["status"],
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
async def get_message(self, message_id: str) -> Message:
|
|
568
|
+
"""Get details for a specific message."""
|
|
569
|
+
response = await self._request("GET", f"/v1/messages/{message_id}")
|
|
570
|
+
return self._parse_message(response["data"])
|
|
571
|
+
|
|
572
|
+
async def replay(self, message_id: str) -> None:
|
|
573
|
+
"""Replay a message for redelivery."""
|
|
574
|
+
await self._request("POST", f"/v1/messages/{message_id}/replay")
|
|
575
|
+
|
|
576
|
+
async def cancel_retry(self, message_id: str) -> None:
|
|
577
|
+
"""Cancel a pending retry."""
|
|
578
|
+
await self._request("POST", f"/v1/messages/{message_id}/cancel")
|
|
579
|
+
|
|
580
|
+
async def retry_now(self, message_id: str) -> None:
|
|
581
|
+
"""Trigger an immediate retry for a pending message."""
|
|
582
|
+
await self._request("POST", f"/v1/messages/{message_id}/retry-now")
|
|
583
|
+
|
|
584
|
+
async def get_logs(
|
|
585
|
+
self,
|
|
586
|
+
*,
|
|
587
|
+
status: Optional[MessageStatus] = None,
|
|
588
|
+
start_time: Optional[Union[datetime, str]] = None,
|
|
589
|
+
end_time: Optional[Union[datetime, str]] = None,
|
|
590
|
+
limit: Optional[int] = None,
|
|
591
|
+
cursor: Optional[str] = None,
|
|
592
|
+
) -> LogsResponse:
|
|
593
|
+
"""Query delivery logs with optional filters."""
|
|
594
|
+
params: dict[str, str] = {}
|
|
595
|
+
if status:
|
|
596
|
+
params["status"] = status
|
|
597
|
+
if start_time:
|
|
598
|
+
params["start_time"] = (
|
|
599
|
+
start_time.isoformat() if isinstance(start_time, datetime) else start_time
|
|
600
|
+
)
|
|
601
|
+
if end_time:
|
|
602
|
+
params["end_time"] = (
|
|
603
|
+
end_time.isoformat() if isinstance(end_time, datetime) else end_time
|
|
604
|
+
)
|
|
605
|
+
if limit:
|
|
606
|
+
params["limit"] = str(limit)
|
|
607
|
+
if cursor:
|
|
608
|
+
params["cursor"] = cursor
|
|
609
|
+
|
|
610
|
+
path = "/v1/logs"
|
|
611
|
+
if params:
|
|
612
|
+
path = f"{path}?{urlencode(params)}"
|
|
613
|
+
|
|
614
|
+
response = await self._request("GET", path)
|
|
615
|
+
return LogsResponse(
|
|
616
|
+
messages=[self._parse_message_summary(m) for m in response["data"]],
|
|
617
|
+
has_more=response["meta"]["has_more"],
|
|
618
|
+
next_cursor=response["meta"].get("next_cursor"),
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
async def get_metrics(self, window: MetricsWindow = "24h") -> Metrics:
|
|
622
|
+
"""Get aggregated delivery metrics."""
|
|
623
|
+
response = await self._request("GET", f"/v1/metrics?window={window}")
|
|
624
|
+
data = response["data"]
|
|
625
|
+
return Metrics(
|
|
626
|
+
window=data["window"],
|
|
627
|
+
total_messages=data["total_messages"],
|
|
628
|
+
succeeded=data["succeeded"],
|
|
629
|
+
failed=data["failed"],
|
|
630
|
+
retries=data["retries"],
|
|
631
|
+
success_rate=data["success_rate"],
|
|
632
|
+
avg_latency_ms=data["avg_latency_ms"],
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
async def get_dlq_messages(
|
|
636
|
+
self,
|
|
637
|
+
*,
|
|
638
|
+
limit: Optional[int] = None,
|
|
639
|
+
cursor: Optional[str] = None,
|
|
640
|
+
) -> DLQMessagesResponse:
|
|
641
|
+
"""List messages in the Dead Letter Queue."""
|
|
642
|
+
params: dict[str, str] = {}
|
|
643
|
+
if limit:
|
|
644
|
+
params["limit"] = str(limit)
|
|
645
|
+
if cursor:
|
|
646
|
+
params["cursor"] = cursor
|
|
647
|
+
|
|
648
|
+
path = "/v1/dlq/messages"
|
|
649
|
+
if params:
|
|
650
|
+
path = f"{path}?{urlencode(params)}"
|
|
651
|
+
|
|
652
|
+
response = await self._request("GET", path)
|
|
653
|
+
data = response["data"]
|
|
654
|
+
return DLQMessagesResponse(
|
|
655
|
+
messages=[self._parse_message_summary(m) for m in (data.get("messages") or [])],
|
|
656
|
+
has_more=data["has_more"],
|
|
657
|
+
next_cursor=data.get("next_cursor"),
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
async def replay_from_dlq(self, message_id: str) -> None:
|
|
661
|
+
"""Replay a message from the Dead Letter Queue."""
|
|
662
|
+
await self._request("POST", f"/v1/dlq/replay/{message_id}")
|
|
663
|
+
|
|
664
|
+
async def list_api_keys(self) -> list[APIKeyInfo]:
|
|
665
|
+
"""List all API keys for the project."""
|
|
666
|
+
response = await self._request("GET", "/v1/api-keys")
|
|
667
|
+
return [
|
|
668
|
+
APIKeyInfo(
|
|
669
|
+
key_id=key["key_id"],
|
|
670
|
+
label=key["label"],
|
|
671
|
+
prefix=key["prefix"],
|
|
672
|
+
created_at=self._parse_datetime(key["created_at"]),
|
|
673
|
+
last_used_at=(
|
|
674
|
+
self._parse_datetime(key["last_used_at"]) if key.get("last_used_at") else None
|
|
675
|
+
),
|
|
676
|
+
)
|
|
677
|
+
for key in response["data"]
|
|
678
|
+
]
|
|
679
|
+
|
|
680
|
+
async def create_api_key(
|
|
681
|
+
self,
|
|
682
|
+
mode: APIKeyMode,
|
|
683
|
+
*,
|
|
684
|
+
label: Optional[str] = None,
|
|
685
|
+
) -> CreateAPIKeyResponse:
|
|
686
|
+
"""Create a new API key."""
|
|
687
|
+
body: dict[str, Any] = {"mode": mode}
|
|
688
|
+
if label:
|
|
689
|
+
body["label"] = label
|
|
690
|
+
|
|
691
|
+
response = await self._request("POST", "/v1/api-keys", json=body)
|
|
692
|
+
data = response["data"]
|
|
693
|
+
return CreateAPIKeyResponse(
|
|
694
|
+
key_id=data["key_id"],
|
|
695
|
+
key=data["key"],
|
|
696
|
+
prefix=data["prefix"],
|
|
697
|
+
label=data["label"],
|
|
698
|
+
created_at=self._parse_datetime(data["created_at"]),
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
async def delete_api_key(self, key_id: str) -> None:
|
|
702
|
+
"""Delete an API key."""
|
|
703
|
+
await self._request("DELETE", f"/v1/api-keys/{key_id}")
|
|
704
|
+
|
|
705
|
+
async def _request(
|
|
706
|
+
self,
|
|
707
|
+
method: str,
|
|
708
|
+
path: str,
|
|
709
|
+
*,
|
|
710
|
+
json: Optional[dict[str, Any]] = None,
|
|
711
|
+
) -> dict[str, Any]:
|
|
712
|
+
"""Make an HTTP request with retries."""
|
|
713
|
+
import asyncio
|
|
714
|
+
|
|
715
|
+
last_error: Optional[Exception] = None
|
|
716
|
+
|
|
717
|
+
for attempt in range(self._retries + 1):
|
|
718
|
+
try:
|
|
719
|
+
response = await self._client.request(method, path, json=json)
|
|
720
|
+
|
|
721
|
+
if not response.is_success:
|
|
722
|
+
self._handle_error_response(response)
|
|
723
|
+
|
|
724
|
+
return response.json() # type: ignore[no-any-return]
|
|
725
|
+
|
|
726
|
+
except HookBridgeError as e:
|
|
727
|
+
if e.status_code and 400 <= e.status_code < 500:
|
|
728
|
+
raise
|
|
729
|
+
last_error = e
|
|
730
|
+
|
|
731
|
+
except httpx.TimeoutException:
|
|
732
|
+
last_error = TimeoutError()
|
|
733
|
+
|
|
734
|
+
except httpx.RequestError as e:
|
|
735
|
+
last_error = NetworkError(str(e))
|
|
736
|
+
|
|
737
|
+
if attempt < self._retries:
|
|
738
|
+
await asyncio.sleep(2**attempt * 0.1)
|
|
739
|
+
|
|
740
|
+
if last_error:
|
|
741
|
+
raise last_error
|
|
742
|
+
raise NetworkError("Request failed")
|
|
743
|
+
|
|
744
|
+
def _handle_error_response(self, response: httpx.Response) -> None:
|
|
745
|
+
"""Handle error responses from the API."""
|
|
746
|
+
try:
|
|
747
|
+
data = response.json()
|
|
748
|
+
code = data.get("error", {}).get("code", "UNKNOWN_ERROR")
|
|
749
|
+
message = data.get("error", {}).get("message", f"HTTP {response.status_code}")
|
|
750
|
+
request_id = data.get("meta", {}).get("request_id")
|
|
751
|
+
except Exception:
|
|
752
|
+
code = "UNKNOWN_ERROR"
|
|
753
|
+
message = f"HTTP {response.status_code}"
|
|
754
|
+
request_id = None
|
|
755
|
+
|
|
756
|
+
if response.status_code == 401:
|
|
757
|
+
raise AuthenticationError(message, request_id)
|
|
758
|
+
elif response.status_code == 404:
|
|
759
|
+
raise NotFoundError(message, request_id)
|
|
760
|
+
elif response.status_code == 409:
|
|
761
|
+
if code == "IDEMPOTENCY_MISMATCH":
|
|
762
|
+
raise IdempotencyError(message, request_id)
|
|
763
|
+
raise HookBridgeError(message, code, request_id, response.status_code)
|
|
764
|
+
elif response.status_code == 429:
|
|
765
|
+
if code == "REPLAY_LIMIT_EXCEEDED":
|
|
766
|
+
raise ReplayLimitError(message, request_id)
|
|
767
|
+
retry_after = response.headers.get("Retry-After")
|
|
768
|
+
raise RateLimitError(
|
|
769
|
+
message, request_id, int(retry_after) if retry_after else None
|
|
770
|
+
)
|
|
771
|
+
else:
|
|
772
|
+
raise HookBridgeError(message, code, request_id, response.status_code)
|
|
773
|
+
|
|
774
|
+
def _parse_message(self, data: dict[str, Any]) -> Message:
|
|
775
|
+
"""Parse a message from API response."""
|
|
776
|
+
return Message(
|
|
777
|
+
id=data["id"],
|
|
778
|
+
project_id=data["project_id"],
|
|
779
|
+
endpoint_id=data["endpoint_id"],
|
|
780
|
+
status=data["status"],
|
|
781
|
+
attempt_count=data["attempt_count"],
|
|
782
|
+
replay_count=data["replay_count"],
|
|
783
|
+
content_type=data["content_type"],
|
|
784
|
+
size_bytes=data["size_bytes"],
|
|
785
|
+
payload_sha256=data["payload_sha256"],
|
|
786
|
+
created_at=self._parse_datetime(data["created_at"]),
|
|
787
|
+
updated_at=self._parse_datetime(data["updated_at"]),
|
|
788
|
+
idempotency_key=data.get("idempotency_key"),
|
|
789
|
+
next_attempt_at=(
|
|
790
|
+
self._parse_datetime(data["next_attempt_at"])
|
|
791
|
+
if data.get("next_attempt_at")
|
|
792
|
+
else None
|
|
793
|
+
),
|
|
794
|
+
last_error=data.get("last_error"),
|
|
795
|
+
response_status=data.get("response_status"),
|
|
796
|
+
response_latency_ms=data.get("response_latency_ms"),
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
def _parse_message_summary(self, data: dict[str, Any]) -> MessageSummary:
|
|
800
|
+
"""Parse a message summary from API response."""
|
|
801
|
+
return MessageSummary(
|
|
802
|
+
message_id=data["message_id"],
|
|
803
|
+
endpoint=data["endpoint"],
|
|
804
|
+
status=data["status"],
|
|
805
|
+
attempt_count=data["attempt_count"],
|
|
806
|
+
created_at=self._parse_datetime(data["created_at"]),
|
|
807
|
+
delivered_at=(
|
|
808
|
+
self._parse_datetime(data["delivered_at"]) if data.get("delivered_at") else None
|
|
809
|
+
),
|
|
810
|
+
response_status=data.get("response_status"),
|
|
811
|
+
response_latency_ms=data.get("response_latency_ms"),
|
|
812
|
+
last_error=data.get("last_error"),
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
@staticmethod
|
|
816
|
+
def _parse_datetime(value: str) -> datetime:
|
|
817
|
+
"""Parse an ISO 8601 datetime string."""
|
|
818
|
+
if "." in value:
|
|
819
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
820
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|