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/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"))