sendara 0.2.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.
- sendara/__init__.py +79 -0
- sendara/_async_http.py +84 -0
- sendara/_config.py +26 -0
- sendara/_params.py +72 -0
- sendara/_sync_http.py +88 -0
- sendara/_transport.py +161 -0
- sendara/async_client.py +100 -0
- sendara/async_resources.py +576 -0
- sendara/client.py +100 -0
- sendara/errors.py +111 -0
- sendara/models.py +237 -0
- sendara/py.typed +0 -0
- sendara/resources.py +546 -0
- sendara/webhooks.py +19 -0
- sendara/webhooks_verify.py +92 -0
- sendara-0.2.0.dist-info/METADATA +217 -0
- sendara-0.2.0.dist-info/RECORD +18 -0
- sendara-0.2.0.dist-info/WHEEL +4 -0
sendara/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from . import models, webhooks
|
|
2
|
+
from .async_client import AsyncSendara
|
|
3
|
+
from .client import Sendara
|
|
4
|
+
from .errors import (
|
|
5
|
+
APIConnectionError,
|
|
6
|
+
APITimeoutError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
PermissionError_,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
SendaraError,
|
|
13
|
+
ServerError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
)
|
|
16
|
+
from .models import (
|
|
17
|
+
ApiKey,
|
|
18
|
+
BillingState,
|
|
19
|
+
BimiDmarc,
|
|
20
|
+
BimiRecord,
|
|
21
|
+
BimiStatus,
|
|
22
|
+
Broadcast,
|
|
23
|
+
Channel,
|
|
24
|
+
Contact,
|
|
25
|
+
ContactList,
|
|
26
|
+
Domain,
|
|
27
|
+
Message,
|
|
28
|
+
MessageType,
|
|
29
|
+
SendResult,
|
|
30
|
+
Suppression,
|
|
31
|
+
Template,
|
|
32
|
+
TestRecipient,
|
|
33
|
+
Upload,
|
|
34
|
+
UsageSummary,
|
|
35
|
+
WebhookDelivery,
|
|
36
|
+
WebhookSubscription,
|
|
37
|
+
)
|
|
38
|
+
from .webhooks_verify import WebhookVerificationError
|
|
39
|
+
|
|
40
|
+
__version__ = "0.2.0"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"Sendara",
|
|
44
|
+
"AsyncSendara",
|
|
45
|
+
"SendaraError",
|
|
46
|
+
"APIConnectionError",
|
|
47
|
+
"APITimeoutError",
|
|
48
|
+
"AuthenticationError",
|
|
49
|
+
"PermissionError_",
|
|
50
|
+
"ValidationError",
|
|
51
|
+
"NotFoundError",
|
|
52
|
+
"ConflictError",
|
|
53
|
+
"RateLimitError",
|
|
54
|
+
"ServerError",
|
|
55
|
+
"WebhookVerificationError",
|
|
56
|
+
"models",
|
|
57
|
+
"webhooks",
|
|
58
|
+
"Channel",
|
|
59
|
+
"MessageType",
|
|
60
|
+
"SendResult",
|
|
61
|
+
"Message",
|
|
62
|
+
"Broadcast",
|
|
63
|
+
"Suppression",
|
|
64
|
+
"Domain",
|
|
65
|
+
"ApiKey",
|
|
66
|
+
"UsageSummary",
|
|
67
|
+
"BillingState",
|
|
68
|
+
"Template",
|
|
69
|
+
"Contact",
|
|
70
|
+
"ContactList",
|
|
71
|
+
"WebhookSubscription",
|
|
72
|
+
"WebhookDelivery",
|
|
73
|
+
"Upload",
|
|
74
|
+
"TestRecipient",
|
|
75
|
+
"BimiStatus",
|
|
76
|
+
"BimiRecord",
|
|
77
|
+
"BimiDmarc",
|
|
78
|
+
"__version__",
|
|
79
|
+
]
|
sendara/_async_http.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._config import ClientConfig
|
|
9
|
+
from ._transport import JsonBody, PreparedRequest, RequestBuilder, connection_error
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AsyncHTTP:
|
|
13
|
+
"""Asynchronous transport over httpx.AsyncClient with retry/backoff."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
config: ClientConfig,
|
|
18
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
self._builder = RequestBuilder(config)
|
|
21
|
+
self._owns_client = http_client is None
|
|
22
|
+
self._client = http_client or httpx.AsyncClient(
|
|
23
|
+
timeout=self._builder.config.timeout
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def config(self) -> ClientConfig:
|
|
28
|
+
return self._builder.config
|
|
29
|
+
|
|
30
|
+
async def request(
|
|
31
|
+
self,
|
|
32
|
+
method: str,
|
|
33
|
+
path: str,
|
|
34
|
+
*,
|
|
35
|
+
json_body: Optional[JsonBody] = None,
|
|
36
|
+
files: Optional[Dict[str, Any]] = None,
|
|
37
|
+
idempotent: Optional[bool] = None,
|
|
38
|
+
) -> Any:
|
|
39
|
+
prepared = self._builder.prepare(
|
|
40
|
+
method, path, json_body=json_body, files=files, idempotent=idempotent
|
|
41
|
+
)
|
|
42
|
+
return await self._send(prepared)
|
|
43
|
+
|
|
44
|
+
async def _send(self, prepared: PreparedRequest) -> Any:
|
|
45
|
+
attempt = 0
|
|
46
|
+
while True:
|
|
47
|
+
try:
|
|
48
|
+
response = await self._client.request(
|
|
49
|
+
prepared.method,
|
|
50
|
+
prepared.url,
|
|
51
|
+
headers=prepared.headers,
|
|
52
|
+
json=prepared.json_body if prepared.files is None else None,
|
|
53
|
+
files=prepared.files,
|
|
54
|
+
timeout=self._builder.config.timeout,
|
|
55
|
+
)
|
|
56
|
+
except httpx.HTTPError as exc:
|
|
57
|
+
if prepared.idempotent and self._builder.should_retry(attempt, None):
|
|
58
|
+
await asyncio.sleep(self._builder.backoff_delay(attempt, None))
|
|
59
|
+
attempt += 1
|
|
60
|
+
continue
|
|
61
|
+
raise connection_error(exc) from None
|
|
62
|
+
|
|
63
|
+
headers = dict(response.headers)
|
|
64
|
+
if prepared.idempotent and self._builder.should_retry(
|
|
65
|
+
attempt, response.status_code
|
|
66
|
+
):
|
|
67
|
+
retry_after = self._builder.retry_after_from_headers(headers)
|
|
68
|
+
await asyncio.sleep(self._builder.backoff_delay(attempt, retry_after))
|
|
69
|
+
attempt += 1
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
return self._builder.interpret(
|
|
73
|
+
response.status_code, response.content, headers
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def aclose(self) -> None:
|
|
77
|
+
if self._owns_client:
|
|
78
|
+
await self._client.aclose()
|
|
79
|
+
|
|
80
|
+
async def __aenter__(self) -> "AsyncHTTP":
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
async def __aexit__(self, *_exc: object) -> None:
|
|
84
|
+
await self.aclose()
|
sendara/_config.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
DEFAULT_BASE_URL = "https://api.sendara.dev"
|
|
6
|
+
DEFAULT_TIMEOUT = 30.0
|
|
7
|
+
DEFAULT_MAX_RETRIES = 3
|
|
8
|
+
DEFAULT_USER_AGENT = "sendara-python/0.2.0"
|
|
9
|
+
|
|
10
|
+
RETRYABLE_STATUS = frozenset({408, 409, 429, 500, 502, 503, 504})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ClientConfig:
|
|
15
|
+
api_key: str
|
|
16
|
+
base_url: str = DEFAULT_BASE_URL
|
|
17
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
18
|
+
max_retries: int = DEFAULT_MAX_RETRIES
|
|
19
|
+
|
|
20
|
+
def normalized(self) -> "ClientConfig":
|
|
21
|
+
return ClientConfig(
|
|
22
|
+
api_key=self.api_key,
|
|
23
|
+
base_url=self.base_url.rstrip("/"),
|
|
24
|
+
timeout=self.timeout,
|
|
25
|
+
max_retries=max(0, self.max_retries),
|
|
26
|
+
)
|
sendara/_params.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
from urllib.parse import quote, urlencode
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def query(params: Dict[str, Any]) -> str:
|
|
8
|
+
clean = {k: v for k, v in params.items() if v is not None and v != ""}
|
|
9
|
+
return f"?{urlencode(clean)}" if clean else ""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def path_segment(value: str) -> str:
|
|
13
|
+
return quote(str(value), safe="")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def email_send_body(
|
|
17
|
+
*,
|
|
18
|
+
to: str,
|
|
19
|
+
subject: str,
|
|
20
|
+
html: Optional[str] = None,
|
|
21
|
+
text: Optional[str] = None,
|
|
22
|
+
from_: Optional[str] = None,
|
|
23
|
+
message_type: Optional[str] = None,
|
|
24
|
+
template_id: Optional[str] = None,
|
|
25
|
+
template_vars: Optional[Dict[str, Any]] = None,
|
|
26
|
+
idempotency_key: Optional[str] = None,
|
|
27
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
28
|
+
test_send: bool = False,
|
|
29
|
+
) -> Dict[str, Any]:
|
|
30
|
+
meta: Dict[str, Any] = dict(metadata or {})
|
|
31
|
+
if from_:
|
|
32
|
+
meta["from_email"] = from_
|
|
33
|
+
body: Dict[str, Any] = {
|
|
34
|
+
"channel": "email",
|
|
35
|
+
"destination": {"email": to},
|
|
36
|
+
"payload": {"subject": subject, "body_html": html, "body_text": text},
|
|
37
|
+
}
|
|
38
|
+
if message_type is not None:
|
|
39
|
+
body["message_type"] = message_type
|
|
40
|
+
if template_id is not None:
|
|
41
|
+
body["template_id"] = template_id
|
|
42
|
+
if template_vars is not None:
|
|
43
|
+
body["template_vars"] = template_vars
|
|
44
|
+
if idempotency_key is not None:
|
|
45
|
+
body["idempotency_key"] = idempotency_key
|
|
46
|
+
if meta:
|
|
47
|
+
body["metadata"] = meta
|
|
48
|
+
if test_send:
|
|
49
|
+
body["test_send"] = True
|
|
50
|
+
return body
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sms_send_body(
|
|
54
|
+
*,
|
|
55
|
+
to: str,
|
|
56
|
+
body: str,
|
|
57
|
+
sender_id: Optional[str] = None,
|
|
58
|
+
message_type: Optional[str] = None,
|
|
59
|
+
idempotency_key: Optional[str] = None,
|
|
60
|
+
) -> Dict[str, Any]:
|
|
61
|
+
payload: Dict[str, Any] = {
|
|
62
|
+
"channel": "sms",
|
|
63
|
+
"destination": {"phone_number": to},
|
|
64
|
+
"payload": {"body": body},
|
|
65
|
+
}
|
|
66
|
+
if message_type is not None:
|
|
67
|
+
payload["message_type"] = message_type
|
|
68
|
+
if idempotency_key is not None:
|
|
69
|
+
payload["idempotency_key"] = idempotency_key
|
|
70
|
+
if sender_id:
|
|
71
|
+
payload["metadata"] = {"sender_id": sender_id}
|
|
72
|
+
return payload
|
sendara/_sync_http.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._config import ClientConfig
|
|
9
|
+
from ._transport import JsonBody, PreparedRequest, RequestBuilder, connection_error
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SyncHTTP:
|
|
13
|
+
"""Synchronous transport over httpx.Client with retry/backoff."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
config: ClientConfig,
|
|
18
|
+
http_client: Optional[httpx.Client] = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
self._builder = RequestBuilder(config)
|
|
21
|
+
self._owns_client = http_client is None
|
|
22
|
+
self._client = http_client or httpx.Client(timeout=self._builder.config.timeout)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def config(self) -> ClientConfig:
|
|
26
|
+
return self._builder.config
|
|
27
|
+
|
|
28
|
+
def request(
|
|
29
|
+
self,
|
|
30
|
+
method: str,
|
|
31
|
+
path: str,
|
|
32
|
+
*,
|
|
33
|
+
json_body: Optional[JsonBody] = None,
|
|
34
|
+
files: Optional[Dict[str, Any]] = None,
|
|
35
|
+
idempotent: Optional[bool] = None,
|
|
36
|
+
) -> Any:
|
|
37
|
+
prepared = self._builder.prepare(
|
|
38
|
+
method, path, json_body=json_body, files=files, idempotent=idempotent
|
|
39
|
+
)
|
|
40
|
+
return self._send(prepared)
|
|
41
|
+
|
|
42
|
+
def _send(self, prepared: PreparedRequest) -> Any:
|
|
43
|
+
attempt = 0
|
|
44
|
+
while True:
|
|
45
|
+
try:
|
|
46
|
+
response = self._client.request(
|
|
47
|
+
prepared.method,
|
|
48
|
+
prepared.url,
|
|
49
|
+
headers=prepared.headers,
|
|
50
|
+
json=prepared.json_body if prepared.files is None else None,
|
|
51
|
+
files=prepared.files,
|
|
52
|
+
timeout=self._builder.config.timeout,
|
|
53
|
+
)
|
|
54
|
+
except httpx.TimeoutException as exc:
|
|
55
|
+
if prepared.idempotent and self._builder.should_retry(attempt, None):
|
|
56
|
+
time.sleep(self._builder.backoff_delay(attempt, None))
|
|
57
|
+
attempt += 1
|
|
58
|
+
continue
|
|
59
|
+
raise connection_error(exc) from None
|
|
60
|
+
except httpx.HTTPError as exc:
|
|
61
|
+
if prepared.idempotent and self._builder.should_retry(attempt, None):
|
|
62
|
+
time.sleep(self._builder.backoff_delay(attempt, None))
|
|
63
|
+
attempt += 1
|
|
64
|
+
continue
|
|
65
|
+
raise connection_error(exc) from None
|
|
66
|
+
|
|
67
|
+
headers = dict(response.headers)
|
|
68
|
+
if prepared.idempotent and self._builder.should_retry(
|
|
69
|
+
attempt, response.status_code
|
|
70
|
+
):
|
|
71
|
+
retry_after = self._builder.retry_after_from_headers(headers)
|
|
72
|
+
time.sleep(self._builder.backoff_delay(attempt, retry_after))
|
|
73
|
+
attempt += 1
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
return self._builder.interpret(
|
|
77
|
+
response.status_code, response.content, headers
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def close(self) -> None:
|
|
81
|
+
if self._owns_client:
|
|
82
|
+
self._client.close()
|
|
83
|
+
|
|
84
|
+
def __enter__(self) -> "SyncHTTP":
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, *_exc: object) -> None:
|
|
88
|
+
self.close()
|
sendara/_transport.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import random
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Dict, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from ._config import RETRYABLE_STATUS, DEFAULT_USER_AGENT, ClientConfig
|
|
10
|
+
from .errors import (
|
|
11
|
+
APIConnectionError,
|
|
12
|
+
SendaraError,
|
|
13
|
+
error_from_response,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
JsonBody = Any
|
|
17
|
+
SAFE_METHODS = frozenset({"GET", "HEAD", "PUT", "DELETE"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class PreparedRequest:
|
|
22
|
+
method: str
|
|
23
|
+
url: str
|
|
24
|
+
headers: Dict[str, str]
|
|
25
|
+
json_body: Optional[JsonBody]
|
|
26
|
+
files: Optional[Dict[str, Any]]
|
|
27
|
+
idempotent: bool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def new_idempotency_key() -> str:
|
|
31
|
+
return str(uuid.uuid4())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def with_idempotency(body: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
35
|
+
payload = dict(body or {})
|
|
36
|
+
if not payload.get("idempotency_key"):
|
|
37
|
+
payload["idempotency_key"] = new_idempotency_key()
|
|
38
|
+
return payload
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RequestBuilder:
|
|
42
|
+
"""Builds prepared requests and interprets responses. Transport-agnostic so
|
|
43
|
+
the sync and async clients share identical request shapes and error mapping.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, config: ClientConfig) -> None:
|
|
47
|
+
self._config = config.normalized()
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def config(self) -> ClientConfig:
|
|
51
|
+
return self._config
|
|
52
|
+
|
|
53
|
+
def prepare(
|
|
54
|
+
self,
|
|
55
|
+
method: str,
|
|
56
|
+
path: str,
|
|
57
|
+
*,
|
|
58
|
+
json_body: Optional[JsonBody] = None,
|
|
59
|
+
files: Optional[Dict[str, Any]] = None,
|
|
60
|
+
idempotent: Optional[bool] = None,
|
|
61
|
+
) -> PreparedRequest:
|
|
62
|
+
headers = {
|
|
63
|
+
"Authorization": f"Bearer {self._config.api_key}",
|
|
64
|
+
"Accept": "application/json",
|
|
65
|
+
"User-Agent": DEFAULT_USER_AGENT,
|
|
66
|
+
}
|
|
67
|
+
if json_body is not None:
|
|
68
|
+
headers["Content-Type"] = "application/json"
|
|
69
|
+
|
|
70
|
+
is_idempotent = (
|
|
71
|
+
method.upper() in SAFE_METHODS if idempotent is None else idempotent
|
|
72
|
+
)
|
|
73
|
+
return PreparedRequest(
|
|
74
|
+
method=method.upper(),
|
|
75
|
+
url=f"{self._config.base_url}{path}",
|
|
76
|
+
headers=headers,
|
|
77
|
+
json_body=json_body,
|
|
78
|
+
files=files,
|
|
79
|
+
idempotent=is_idempotent,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def backoff_delay(self, attempt: int, retry_after: Optional[float]) -> float:
|
|
83
|
+
if retry_after is not None and retry_after >= 0:
|
|
84
|
+
return retry_after
|
|
85
|
+
base = min(0.5 * (2**attempt), 8.0)
|
|
86
|
+
return base + random.uniform(0, base * 0.25)
|
|
87
|
+
|
|
88
|
+
def should_retry(self, attempt: int, status: Optional[int]) -> bool:
|
|
89
|
+
if attempt >= self._config.max_retries:
|
|
90
|
+
return False
|
|
91
|
+
if status is None:
|
|
92
|
+
return True
|
|
93
|
+
return status in RETRYABLE_STATUS
|
|
94
|
+
|
|
95
|
+
def interpret(
|
|
96
|
+
self,
|
|
97
|
+
status: int,
|
|
98
|
+
body_bytes: bytes,
|
|
99
|
+
headers: Dict[str, str],
|
|
100
|
+
) -> Any:
|
|
101
|
+
request_id = _header(headers, "x-request-id")
|
|
102
|
+
if 200 <= status < 300:
|
|
103
|
+
if status == 204 or not body_bytes:
|
|
104
|
+
return None
|
|
105
|
+
return json.loads(body_bytes)
|
|
106
|
+
|
|
107
|
+
code, message = _parse_error_envelope(body_bytes, status)
|
|
108
|
+
raise error_from_response(
|
|
109
|
+
status,
|
|
110
|
+
code,
|
|
111
|
+
message,
|
|
112
|
+
retry_after=_retry_after_seconds(headers),
|
|
113
|
+
request_id=request_id,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def retry_after_from_headers(self, headers: Dict[str, str]) -> Optional[float]:
|
|
117
|
+
return _retry_after_seconds(headers)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _header(headers: Dict[str, str], name: str) -> Optional[str]:
|
|
121
|
+
lowered = name.lower()
|
|
122
|
+
for key, value in headers.items():
|
|
123
|
+
if key.lower() == lowered:
|
|
124
|
+
return value
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _retry_after_seconds(headers: Dict[str, str]) -> Optional[float]:
|
|
129
|
+
raw = _header(headers, "retry-after")
|
|
130
|
+
if not raw:
|
|
131
|
+
return None
|
|
132
|
+
try:
|
|
133
|
+
return float(raw)
|
|
134
|
+
except ValueError:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_error_envelope(body_bytes: bytes, status: int) -> Tuple[str, str]:
|
|
139
|
+
if not body_bytes:
|
|
140
|
+
return ("http_error", f"HTTP {status}")
|
|
141
|
+
try:
|
|
142
|
+
payload = json.loads(body_bytes)
|
|
143
|
+
except ValueError:
|
|
144
|
+
return (
|
|
145
|
+
"http_error",
|
|
146
|
+
body_bytes.decode("utf-8", "replace")[:500] or f"HTTP {status}",
|
|
147
|
+
)
|
|
148
|
+
if isinstance(payload, dict):
|
|
149
|
+
err = payload.get("error")
|
|
150
|
+
if isinstance(err, dict):
|
|
151
|
+
return (
|
|
152
|
+
str(err.get("code", "error")),
|
|
153
|
+
str(err.get("message", f"HTTP {status}")),
|
|
154
|
+
)
|
|
155
|
+
if "message" in payload:
|
|
156
|
+
return (str(payload.get("code", "error")), str(payload["message"]))
|
|
157
|
+
return ("http_error", f"HTTP {status}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def connection_error(exc: Exception) -> SendaraError:
|
|
161
|
+
return APIConnectionError(f"Request failed: {exc}")
|
sendara/async_client.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ._async_http import AsyncHTTP
|
|
8
|
+
from ._config import (
|
|
9
|
+
DEFAULT_BASE_URL,
|
|
10
|
+
DEFAULT_MAX_RETRIES,
|
|
11
|
+
DEFAULT_TIMEOUT,
|
|
12
|
+
ClientConfig,
|
|
13
|
+
)
|
|
14
|
+
from .async_resources import (
|
|
15
|
+
AsyncApiKeys,
|
|
16
|
+
AsyncBilling,
|
|
17
|
+
AsyncBroadcasts,
|
|
18
|
+
AsyncContacts,
|
|
19
|
+
AsyncDomains,
|
|
20
|
+
AsyncEmails,
|
|
21
|
+
AsyncLists,
|
|
22
|
+
AsyncMessages,
|
|
23
|
+
AsyncSms,
|
|
24
|
+
AsyncSuppressions,
|
|
25
|
+
AsyncTemplates,
|
|
26
|
+
AsyncTestRecipients,
|
|
27
|
+
AsyncUploads,
|
|
28
|
+
AsyncUsage,
|
|
29
|
+
AsyncWebhooks,
|
|
30
|
+
)
|
|
31
|
+
from .errors import SendaraError
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AsyncSendara:
|
|
35
|
+
"""Asynchronous client for the Sendara messaging API (httpx.AsyncClient).
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
import asyncio
|
|
39
|
+
from sendara import AsyncSendara
|
|
40
|
+
|
|
41
|
+
async def main():
|
|
42
|
+
async with AsyncSendara("sk_live_...") as client:
|
|
43
|
+
await client.emails.send(to="user@example.com", subject="Hi",
|
|
44
|
+
html="<p>Hi</p>")
|
|
45
|
+
|
|
46
|
+
asyncio.run(main())
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
api_key: str,
|
|
52
|
+
*,
|
|
53
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
54
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
55
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
56
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
if not api_key:
|
|
59
|
+
raise SendaraError(0, "missing_api_key", "An API key is required")
|
|
60
|
+
config = ClientConfig(
|
|
61
|
+
api_key=api_key,
|
|
62
|
+
base_url=base_url,
|
|
63
|
+
timeout=timeout,
|
|
64
|
+
max_retries=max_retries,
|
|
65
|
+
)
|
|
66
|
+
self._http = AsyncHTTP(config, http_client=http_client)
|
|
67
|
+
|
|
68
|
+
self.emails = AsyncEmails(self._http)
|
|
69
|
+
self.sms = AsyncSms(self._http)
|
|
70
|
+
self.broadcasts = AsyncBroadcasts(self._http)
|
|
71
|
+
self.messages = AsyncMessages(self._http)
|
|
72
|
+
self.suppressions = AsyncSuppressions(self._http)
|
|
73
|
+
self.domains = AsyncDomains(self._http)
|
|
74
|
+
self.api_keys = AsyncApiKeys(self._http)
|
|
75
|
+
self.usage = AsyncUsage(self._http)
|
|
76
|
+
self.billing = AsyncBilling(self._http)
|
|
77
|
+
self.templates = AsyncTemplates(self._http)
|
|
78
|
+
self.contacts = AsyncContacts(self._http)
|
|
79
|
+
self.lists = AsyncLists(self._http)
|
|
80
|
+
self.webhooks = AsyncWebhooks(self._http)
|
|
81
|
+
self.uploads = AsyncUploads(self._http)
|
|
82
|
+
self.test_recipients = AsyncTestRecipients(self._http)
|
|
83
|
+
|
|
84
|
+
async def send(self, request: dict) -> Any:
|
|
85
|
+
return await self.emails.send_raw(request)
|
|
86
|
+
|
|
87
|
+
async def send_batch(self, requests: List[dict]) -> Any:
|
|
88
|
+
return await self.emails.send_batch(requests)
|
|
89
|
+
|
|
90
|
+
async def request(self, method: str, path: str, body: Optional[Any] = None) -> Any:
|
|
91
|
+
return await self._http.request(method, path, json_body=body)
|
|
92
|
+
|
|
93
|
+
async def aclose(self) -> None:
|
|
94
|
+
await self._http.aclose()
|
|
95
|
+
|
|
96
|
+
async def __aenter__(self) -> "AsyncSendara":
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
async def __aexit__(self, *_exc: object) -> None:
|
|
100
|
+
await self.aclose()
|