sendmux-core 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.
- sendmux_core/__init__.py +17 -0
- sendmux_core/auth.py +17 -0
- sendmux_core/errors.py +101 -0
- sendmux_core/headers.py +15 -0
- sendmux_core/pagination.py +41 -0
- sendmux_core/retry.py +94 -0
- sendmux_core-1.0.0.dist-info/METADATA +13 -0
- sendmux_core-1.0.0.dist-info/RECORD +9 -0
- sendmux_core-1.0.0.dist-info/WHEEL +4 -0
sendmux_core/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from sendmux_core.auth import configure_auth, validate_api_key
|
|
2
|
+
from sendmux_core.errors import SendmuxApiError
|
|
3
|
+
from sendmux_core.headers import conditional_headers, idempotency_headers
|
|
4
|
+
from sendmux_core.pagination import CursorPage, iter_cursor_pages
|
|
5
|
+
from sendmux_core.retry import RetryOptions
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CursorPage",
|
|
9
|
+
"RetryOptions",
|
|
10
|
+
"SendmuxApiError",
|
|
11
|
+
"conditional_headers",
|
|
12
|
+
"configure_auth",
|
|
13
|
+
"idempotency_headers",
|
|
14
|
+
"iter_cursor_pages",
|
|
15
|
+
"validate_api_key",
|
|
16
|
+
]
|
|
17
|
+
|
sendmux_core/auth.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
ApiKeySurface = Literal["root", "mailbox"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_api_key(api_key: str, *, surface: ApiKeySurface) -> None:
|
|
9
|
+
expected_prefix = "smx_mbx_" if surface == "mailbox" else "smx_root_"
|
|
10
|
+
if not api_key.startswith(expected_prefix):
|
|
11
|
+
raise ValueError(f"Expected a {expected_prefix} API key for the {surface} surface")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def configure_auth(configuration: Any, *, api_key: str) -> None:
|
|
15
|
+
configuration.api_key["BearerAuth"] = api_key
|
|
16
|
+
configuration.api_key_prefix["BearerAuth"] = "Bearer"
|
|
17
|
+
|
sendmux_core/errors.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class SendmuxApiError(Exception):
|
|
10
|
+
status_code: int | None
|
|
11
|
+
code: str
|
|
12
|
+
message: str
|
|
13
|
+
retryable: bool
|
|
14
|
+
request_id: str | None
|
|
15
|
+
headers: Mapping[str, str]
|
|
16
|
+
raw_body: str | None = None
|
|
17
|
+
|
|
18
|
+
def __str__(self) -> str:
|
|
19
|
+
status = f"{self.status_code} " if self.status_code is not None else ""
|
|
20
|
+
return f"{status}{self.code}: {self.message}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def map_api_exception(exc: Any) -> SendmuxApiError:
|
|
24
|
+
headers = _headers(exc)
|
|
25
|
+
payload = _payload(exc)
|
|
26
|
+
detail = _error_detail(payload)
|
|
27
|
+
status_code = getattr(exc, "status", None)
|
|
28
|
+
code = _string(detail.get("code")) or "api_error"
|
|
29
|
+
message = _string(detail.get("message")) or _string(getattr(exc, "reason", None)) or "Sendmux API request failed"
|
|
30
|
+
retryable = _bool(detail.get("retryable"))
|
|
31
|
+
|
|
32
|
+
return SendmuxApiError(
|
|
33
|
+
status_code=status_code if isinstance(status_code, int) else None,
|
|
34
|
+
code=code,
|
|
35
|
+
message=message,
|
|
36
|
+
retryable=retryable if retryable is not None else _default_retryable(status_code),
|
|
37
|
+
request_id=_request_id(payload) or _header(headers, "x-request-id"),
|
|
38
|
+
headers=headers,
|
|
39
|
+
raw_body=getattr(exc, "body", None),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _headers(exc: Any) -> Mapping[str, str]:
|
|
44
|
+
value = getattr(exc, "headers", None)
|
|
45
|
+
if not value:
|
|
46
|
+
return {}
|
|
47
|
+
return {str(key): str(child) for key, child in dict(value).items()}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _payload(exc: Any) -> Any:
|
|
51
|
+
data = getattr(exc, "data", None)
|
|
52
|
+
if data is not None:
|
|
53
|
+
if hasattr(data, "to_dict"):
|
|
54
|
+
return data.to_dict()
|
|
55
|
+
return data
|
|
56
|
+
|
|
57
|
+
body = getattr(exc, "body", None)
|
|
58
|
+
if isinstance(body, str) and body:
|
|
59
|
+
try:
|
|
60
|
+
return json.loads(body)
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
return None
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _error_detail(payload: Any) -> Mapping[str, Any]:
|
|
67
|
+
if not isinstance(payload, Mapping):
|
|
68
|
+
return {}
|
|
69
|
+
error = payload.get("error")
|
|
70
|
+
if isinstance(error, Mapping):
|
|
71
|
+
return error
|
|
72
|
+
return {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _request_id(payload: Any) -> str | None:
|
|
76
|
+
if not isinstance(payload, Mapping):
|
|
77
|
+
return None
|
|
78
|
+
meta = payload.get("meta")
|
|
79
|
+
if not isinstance(meta, Mapping):
|
|
80
|
+
return None
|
|
81
|
+
return _string(meta.get("request_id"))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _header(headers: Mapping[str, str], name: str) -> str | None:
|
|
85
|
+
name = name.lower()
|
|
86
|
+
for key, value in headers.items():
|
|
87
|
+
if key.lower() == name:
|
|
88
|
+
return value
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _string(value: Any) -> str | None:
|
|
93
|
+
return value if isinstance(value, str) and value else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _bool(value: Any) -> bool | None:
|
|
97
|
+
return value if isinstance(value, bool) else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _default_retryable(status_code: Any) -> bool:
|
|
101
|
+
return isinstance(status_code, int) and (status_code == 429 or status_code >= 500)
|
sendmux_core/headers.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def idempotency_headers(idempotency_key: str | None = None) -> dict[str, str]:
|
|
5
|
+
return {"Idempotency-Key": idempotency_key} if idempotency_key else {}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def conditional_headers(*, if_match: str | None = None, if_none_match: str | None = None) -> dict[str, str]:
|
|
9
|
+
headers: dict[str, str] = {}
|
|
10
|
+
if if_match:
|
|
11
|
+
headers["If-Match"] = if_match
|
|
12
|
+
if if_none_match:
|
|
13
|
+
headers["If-None-Match"] = if_none_match
|
|
14
|
+
return headers
|
|
15
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Generic, Protocol, TypeVar
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PaginationMeta(Protocol):
|
|
11
|
+
has_more: bool
|
|
12
|
+
next_cursor: str | None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CursorResponse(Protocol[T]):
|
|
16
|
+
data: Sequence[T]
|
|
17
|
+
pagination: PaginationMeta
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class CursorPage(Generic[T]):
|
|
22
|
+
data: Sequence[T]
|
|
23
|
+
has_more: bool
|
|
24
|
+
next_cursor: str | None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def iter_cursor_pages(fetch_page: Callable[[str | None], CursorResponse[T]]) -> Iterator[T]:
|
|
28
|
+
cursor: str | None = None
|
|
29
|
+
|
|
30
|
+
while True:
|
|
31
|
+
response = fetch_page(cursor)
|
|
32
|
+
for item in response.data:
|
|
33
|
+
yield item
|
|
34
|
+
|
|
35
|
+
if not response.pagination.has_more:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
next_cursor = response.pagination.next_cursor
|
|
39
|
+
if not next_cursor or next_cursor == cursor:
|
|
40
|
+
raise RuntimeError("Sendmux cursor pagination did not return a new next_cursor")
|
|
41
|
+
cursor = next_cursor
|
sendmux_core/retry.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from email.utils import parsedate_to_datetime
|
|
7
|
+
from typing import Any, Callable, Mapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class RetryOptions:
|
|
12
|
+
max_attempts: int = 3
|
|
13
|
+
base_delay_seconds: float = 0.25
|
|
14
|
+
max_delay_seconds: float = 4.0
|
|
15
|
+
sleep: Callable[[float], None] = time.sleep
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RetryingRestClient:
|
|
19
|
+
def __init__(self, inner: Any, *, retry_options: RetryOptions | None = None) -> None:
|
|
20
|
+
self._inner = inner
|
|
21
|
+
self._options = retry_options or RetryOptions()
|
|
22
|
+
|
|
23
|
+
def request(self, method: str, url: str, **kwargs: Any) -> Any:
|
|
24
|
+
attempt = 1
|
|
25
|
+
while True:
|
|
26
|
+
response = self._inner.request(method, url, **kwargs)
|
|
27
|
+
if not self._should_retry(method, kwargs.get("headers") or {}, response, attempt):
|
|
28
|
+
return response
|
|
29
|
+
|
|
30
|
+
delay = self._delay_seconds(response.headers, attempt)
|
|
31
|
+
_release_response(response)
|
|
32
|
+
self._options.sleep(delay)
|
|
33
|
+
attempt += 1
|
|
34
|
+
|
|
35
|
+
def _should_retry(self, method: str, headers: Mapping[str, str], response: Any, attempt: int) -> bool:
|
|
36
|
+
if attempt >= self._options.max_attempts:
|
|
37
|
+
return False
|
|
38
|
+
if response.status not in {408, 409, 425, 429, 500, 502, 503, 504}:
|
|
39
|
+
return False
|
|
40
|
+
method = method.upper()
|
|
41
|
+
if method in {"GET", "HEAD", "OPTIONS"}:
|
|
42
|
+
return True
|
|
43
|
+
return method == "POST" and _header(headers, "idempotency-key") is not None
|
|
44
|
+
|
|
45
|
+
def _delay_seconds(self, headers: Mapping[str, str], attempt: int) -> float:
|
|
46
|
+
retry_after = _retry_after_seconds(_header(headers, "retry-after"))
|
|
47
|
+
if retry_after is not None:
|
|
48
|
+
return min(retry_after, self._options.max_delay_seconds)
|
|
49
|
+
|
|
50
|
+
rate_limit_reset = _rate_limit_reset_seconds(_header(headers, "x-ratelimit-reset"))
|
|
51
|
+
if rate_limit_reset is not None:
|
|
52
|
+
return min(rate_limit_reset, self._options.max_delay_seconds)
|
|
53
|
+
|
|
54
|
+
exponential = min(
|
|
55
|
+
self._options.base_delay_seconds * (2 ** (attempt - 1)),
|
|
56
|
+
self._options.max_delay_seconds,
|
|
57
|
+
)
|
|
58
|
+
return random.uniform(0, exponential)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _header(headers: Mapping[str, str], name: str) -> str | None:
|
|
62
|
+
name = name.lower()
|
|
63
|
+
for key, value in headers.items():
|
|
64
|
+
if key.lower() == name:
|
|
65
|
+
return value
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _retry_after_seconds(value: str | None) -> float | None:
|
|
70
|
+
if not value:
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
return max(float(value), 0.0)
|
|
74
|
+
except ValueError:
|
|
75
|
+
try:
|
|
76
|
+
return max(parsedate_to_datetime(value).timestamp() - time.time(), 0.0)
|
|
77
|
+
except (TypeError, ValueError):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _rate_limit_reset_seconds(value: str | None) -> float | None:
|
|
82
|
+
if not value:
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
return max(float(value) - time.time(), 0.0)
|
|
86
|
+
except ValueError:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _release_response(response: Any) -> None:
|
|
91
|
+
raw = getattr(response, "response", None)
|
|
92
|
+
release_conn = getattr(raw, "release_conn", None)
|
|
93
|
+
if callable(release_conn):
|
|
94
|
+
release_conn()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sendmux-core
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Shared runtime helpers for the Sendmux Python SDK packages.
|
|
5
|
+
Project-URL: Repository, https://github.com/Sendmux/sendmux-sdk
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: urllib3<3,>=2.3.0
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# sendmux-core
|
|
11
|
+
|
|
12
|
+
Shared runtime helpers for the Sendmux Python SDK packages.
|
|
13
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
sendmux_core/__init__.py,sha256=yjbS2853lFzEvlXATXtweS_unHkDuZ2xvMlSM2FIGQQ,497
|
|
2
|
+
sendmux_core/auth.py,sha256=HiwZcpv83bCeaVQpu8hR7kvNrmudHi3OLZoQ7jRPU_E,575
|
|
3
|
+
sendmux_core/errors.py,sha256=cnF_ts7WAX7sjwXzUwWRdVkZmBJwOzfwFEpJhIL02l0,2899
|
|
4
|
+
sendmux_core/headers.py,sha256=tYTUQV2Xqpa4_lyod_fslY65xj2ASxKZCitH-l0K_tE,482
|
|
5
|
+
sendmux_core/pagination.py,sha256=EjPENnfzxbzcmN-9S8AYaUWTdDfJIbsRcH5MAmLpgJc,1020
|
|
6
|
+
sendmux_core/retry.py,sha256=lT41uux8NPbV1XCss9jASpfyJMeFSGLSd-jMfRmsibE,3142
|
|
7
|
+
sendmux_core-1.0.0.dist-info/METADATA,sha256=S2hVVCkdppGqEU3OX2Oz_vnA3Tsff9Gcoz0pBD0jao4,364
|
|
8
|
+
sendmux_core-1.0.0.dist-info/WHEEL,sha256=KvPDMDa6F56cTSWKcFW-N0kTgjEkQQ6hmuMKcPkRB_Y,87
|
|
9
|
+
sendmux_core-1.0.0.dist-info/RECORD,,
|