replylayer 0.14.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.
- replylayer/__init__.py +143 -0
- replylayer/_client.py +128 -0
- replylayer/_http.py +326 -0
- replylayer/_pagination.py +34 -0
- replylayer/errors.py +152 -0
- replylayer/py.typed +0 -0
- replylayer/resources/__init__.py +0 -0
- replylayer/resources/account.py +36 -0
- replylayer/resources/api_keys.py +59 -0
- replylayer/resources/attachments.py +115 -0
- replylayer/resources/domains.py +79 -0
- replylayer/resources/drafts.py +342 -0
- replylayer/resources/health.py +21 -0
- replylayer/resources/inbound_blocklist.py +93 -0
- replylayer/resources/legal_holds.py +107 -0
- replylayer/resources/mailboxes.py +768 -0
- replylayer/resources/messages.py +425 -0
- replylayer/resources/recipients.py +59 -0
- replylayer/resources/suppressions.py +84 -0
- replylayer/resources/threads.py +117 -0
- replylayer/resources/webhooks.py +175 -0
- replylayer/types.py +1578 -0
- replylayer-0.14.0.dist-info/METADATA +502 -0
- replylayer-0.14.0.dist-info/RECORD +25 -0
- replylayer-0.14.0.dist-info/WHEEL +4 -0
replylayer/__init__.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from ._client import ReplyLayer, AsyncReplyLayer
|
|
2
|
+
from ._http import RetryInfo
|
|
3
|
+
from .resources.webhooks import verify_webhook_signature
|
|
4
|
+
from .errors import (
|
|
5
|
+
ReplyLayerError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
ForbiddenError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
ValidationError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
WebhookSignatureError,
|
|
12
|
+
# Migration 040 — scheduled-send error surface.
|
|
13
|
+
SchedulingError,
|
|
14
|
+
TimezoneRequiredError,
|
|
15
|
+
)
|
|
16
|
+
from .types import (
|
|
17
|
+
WebhookSummary,
|
|
18
|
+
WebhookDeliverySummary,
|
|
19
|
+
WebhookDeliveryStatus,
|
|
20
|
+
ListWebhooksResponse,
|
|
21
|
+
ListDeliveriesResponse,
|
|
22
|
+
RetryDeliveryResponse,
|
|
23
|
+
RotateWebhookSecretResponse,
|
|
24
|
+
TestWebhookResponse,
|
|
25
|
+
DraftResponse,
|
|
26
|
+
DraftSummary,
|
|
27
|
+
ListDraftsResponse,
|
|
28
|
+
CreateDraftRequest,
|
|
29
|
+
UpdateDraftRequest,
|
|
30
|
+
ListDraftsParams,
|
|
31
|
+
# Outbound attachments (plans/outbound-attachment-ux.md).
|
|
32
|
+
OutboundAttachmentScanStatus,
|
|
33
|
+
UploadAttachmentResponse,
|
|
34
|
+
ConsumedAttachmentResponse,
|
|
35
|
+
GetUploadAttachmentResponse,
|
|
36
|
+
# Migration 036 — recipient allowlist types.
|
|
37
|
+
RecipientPolicyMode,
|
|
38
|
+
AllowlistEntry,
|
|
39
|
+
ListAllowlistResponse,
|
|
40
|
+
AddAllowlistResponse,
|
|
41
|
+
BulkAddAllowlistResponse,
|
|
42
|
+
DeleteAllowlistResponse,
|
|
43
|
+
# Sprint 039 — email-or-domain pattern in allowlist + suppression entries.
|
|
44
|
+
RecipientPatternType,
|
|
45
|
+
# Migration 047 — inbound firewall types.
|
|
46
|
+
SenderPolicyMode,
|
|
47
|
+
FirewallBlock,
|
|
48
|
+
# URL-reputation (Google Web Risk) — additive ScanResult fields +
|
|
49
|
+
# message.received safety signal. Server-side sanitizer enforces
|
|
50
|
+
# expiry; SDK consumers do not need client-side expiry logic.
|
|
51
|
+
ScanResult,
|
|
52
|
+
WebRiskLearnMore,
|
|
53
|
+
WebRiskWarning,
|
|
54
|
+
WebRiskThreatType,
|
|
55
|
+
MessageReceivedSafetySignal,
|
|
56
|
+
MessageReceivedWebhookPayload,
|
|
57
|
+
# PR 6 — HITL review queue webhook payload types.
|
|
58
|
+
HitlMode,
|
|
59
|
+
MessageReviewOrigin,
|
|
60
|
+
MessageReviewQueuedWebhookPayload,
|
|
61
|
+
MessageReviewApprovedWebhookPayload,
|
|
62
|
+
MessageReviewDeniedWebhookPayload,
|
|
63
|
+
# PR 7 — scanner-emit HITL review surface.
|
|
64
|
+
ReviewQueueTriggerSource,
|
|
65
|
+
# PR 9 — outbound PII safety tuning.
|
|
66
|
+
OutboundPiiType,
|
|
67
|
+
OutboundPiiAction,
|
|
68
|
+
OutboundPiiPolicy,
|
|
69
|
+
OutboundReviewApprovalNotePolicy,
|
|
70
|
+
OutboundReviewPolicy,
|
|
71
|
+
ScannerPolicy,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
__version__ = "0.14.0"
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"ReplyLayer",
|
|
78
|
+
"AsyncReplyLayer",
|
|
79
|
+
"RetryInfo",
|
|
80
|
+
"verify_webhook_signature",
|
|
81
|
+
"ReplyLayerError",
|
|
82
|
+
"AuthenticationError",
|
|
83
|
+
"ForbiddenError",
|
|
84
|
+
"NotFoundError",
|
|
85
|
+
"ValidationError",
|
|
86
|
+
"RateLimitError",
|
|
87
|
+
"WebhookSignatureError",
|
|
88
|
+
# Migration 040 — scheduled-send.
|
|
89
|
+
"SchedulingError",
|
|
90
|
+
"TimezoneRequiredError",
|
|
91
|
+
"WebhookSummary",
|
|
92
|
+
"WebhookDeliverySummary",
|
|
93
|
+
"WebhookDeliveryStatus",
|
|
94
|
+
"ListWebhooksResponse",
|
|
95
|
+
"ListDeliveriesResponse",
|
|
96
|
+
"RetryDeliveryResponse",
|
|
97
|
+
"RotateWebhookSecretResponse",
|
|
98
|
+
"TestWebhookResponse",
|
|
99
|
+
"DraftResponse",
|
|
100
|
+
"DraftSummary",
|
|
101
|
+
"ListDraftsResponse",
|
|
102
|
+
"CreateDraftRequest",
|
|
103
|
+
"UpdateDraftRequest",
|
|
104
|
+
"ListDraftsParams",
|
|
105
|
+
# Outbound attachments.
|
|
106
|
+
"OutboundAttachmentScanStatus",
|
|
107
|
+
"UploadAttachmentResponse",
|
|
108
|
+
"ConsumedAttachmentResponse",
|
|
109
|
+
"GetUploadAttachmentResponse",
|
|
110
|
+
"RecipientPolicyMode",
|
|
111
|
+
"AllowlistEntry",
|
|
112
|
+
"ListAllowlistResponse",
|
|
113
|
+
"AddAllowlistResponse",
|
|
114
|
+
"BulkAddAllowlistResponse",
|
|
115
|
+
"DeleteAllowlistResponse",
|
|
116
|
+
"RecipientPatternType",
|
|
117
|
+
# Migration 047 — inbound firewall.
|
|
118
|
+
"SenderPolicyMode",
|
|
119
|
+
"FirewallBlock",
|
|
120
|
+
# URL reputation — Web Risk
|
|
121
|
+
"ScanResult",
|
|
122
|
+
"WebRiskLearnMore",
|
|
123
|
+
"WebRiskWarning",
|
|
124
|
+
"WebRiskThreatType",
|
|
125
|
+
"MessageReceivedSafetySignal",
|
|
126
|
+
"MessageReceivedWebhookPayload",
|
|
127
|
+
# PR 6 — HITL review queue.
|
|
128
|
+
"HitlMode",
|
|
129
|
+
"MessageReviewOrigin",
|
|
130
|
+
"MessageReviewQueuedWebhookPayload",
|
|
131
|
+
"MessageReviewApprovedWebhookPayload",
|
|
132
|
+
"MessageReviewDeniedWebhookPayload",
|
|
133
|
+
# PR 7 — scanner-emit HITL.
|
|
134
|
+
"ReviewQueueTriggerSource",
|
|
135
|
+
# PR 9 — outbound PII safety tuning.
|
|
136
|
+
"OutboundPiiType",
|
|
137
|
+
"OutboundPiiAction",
|
|
138
|
+
"OutboundPiiPolicy",
|
|
139
|
+
"OutboundReviewApprovalNotePolicy",
|
|
140
|
+
"OutboundReviewPolicy",
|
|
141
|
+
"ScannerPolicy",
|
|
142
|
+
"__version__",
|
|
143
|
+
]
|
replylayer/_client.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# typing.Self lands in 3.11; pyproject.toml targets 3.10 (`requires-python
|
|
4
|
+
# = ">=3.10"`), so on 3.10 `from typing import Self` raises ImportError
|
|
5
|
+
# at module load. typing_extensions backports it. Round-2 audit P2 pin:
|
|
6
|
+
# without this, `import replylayer` fails on the advertised floor.
|
|
7
|
+
from typing_extensions import Self
|
|
8
|
+
|
|
9
|
+
from .errors import ReplyLayerError
|
|
10
|
+
from ._http import SyncHttpClient, AsyncHttpClient, _MAX_RETRY_AFTER_SECONDS, OnRetry
|
|
11
|
+
from .resources.mailboxes import SyncMailboxes, AsyncMailboxes
|
|
12
|
+
from .resources.messages import SyncMessages, AsyncMessages
|
|
13
|
+
from .resources.drafts import SyncDrafts, AsyncDrafts
|
|
14
|
+
from .resources.threads import SyncThreads, AsyncThreads
|
|
15
|
+
from .resources.attachments import SyncAttachments, AsyncAttachments
|
|
16
|
+
from .resources.webhooks import SyncWebhooks, AsyncWebhooks
|
|
17
|
+
from .resources.recipients import SyncRecipients, AsyncRecipients
|
|
18
|
+
from .resources.suppressions import SyncSuppressions, AsyncSuppressions
|
|
19
|
+
from .resources.inbound_blocklist import SyncInboundBlocklist, AsyncInboundBlocklist
|
|
20
|
+
from .resources.api_keys import SyncApiKeys, AsyncApiKeys
|
|
21
|
+
from .resources.account import SyncAccount, AsyncAccount
|
|
22
|
+
from .resources.health import SyncHealth, AsyncHealth
|
|
23
|
+
from .resources.domains import SyncDomains, AsyncDomains
|
|
24
|
+
from .resources.legal_holds import SyncLegalHolds, AsyncLegalHolds
|
|
25
|
+
|
|
26
|
+
_DEFAULT_BASE_URL = "https://api.replylayer.ai"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ReplyLayer:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
api_key: str,
|
|
34
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
35
|
+
max_retries: int = 3,
|
|
36
|
+
timeout: float = 30.0,
|
|
37
|
+
max_retry_after_seconds: float = _MAX_RETRY_AFTER_SECONDS,
|
|
38
|
+
on_retry: OnRetry | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
if not api_key:
|
|
41
|
+
raise ReplyLayerError(
|
|
42
|
+
0, "MISSING_API_KEY",
|
|
43
|
+
"api_key is required. Get yours at https://app.replylayer.ai/connect",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
self._http = SyncHttpClient(
|
|
47
|
+
api_key=api_key,
|
|
48
|
+
base_url=base_url.rstrip("/"),
|
|
49
|
+
max_retries=max_retries,
|
|
50
|
+
timeout=timeout,
|
|
51
|
+
max_retry_after_seconds=max_retry_after_seconds,
|
|
52
|
+
on_retry=on_retry,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
self.domains = SyncDomains(self._http)
|
|
56
|
+
self.mailboxes = SyncMailboxes(self._http)
|
|
57
|
+
self.messages = SyncMessages(self._http)
|
|
58
|
+
self.drafts = SyncDrafts(self._http)
|
|
59
|
+
self.threads = SyncThreads(self._http)
|
|
60
|
+
self.attachments = SyncAttachments(self._http)
|
|
61
|
+
self.webhooks = SyncWebhooks(self._http)
|
|
62
|
+
self.recipients = SyncRecipients(self._http)
|
|
63
|
+
self.suppressions = SyncSuppressions(self._http)
|
|
64
|
+
self.inbound_blocklist = SyncInboundBlocklist(self._http)
|
|
65
|
+
self.api_keys = SyncApiKeys(self._http)
|
|
66
|
+
self.account = SyncAccount(self._http)
|
|
67
|
+
self.health = SyncHealth(self._http)
|
|
68
|
+
self.legal_holds = SyncLegalHolds(self._http)
|
|
69
|
+
|
|
70
|
+
def close(self) -> None:
|
|
71
|
+
self._http.close()
|
|
72
|
+
|
|
73
|
+
def __enter__(self) -> Self:
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def __exit__(self, *args: object) -> None:
|
|
77
|
+
self.close()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AsyncReplyLayer:
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
api_key: str,
|
|
85
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
86
|
+
max_retries: int = 3,
|
|
87
|
+
timeout: float = 30.0,
|
|
88
|
+
max_retry_after_seconds: float = _MAX_RETRY_AFTER_SECONDS,
|
|
89
|
+
on_retry: OnRetry | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
if not api_key:
|
|
92
|
+
raise ReplyLayerError(
|
|
93
|
+
0, "MISSING_API_KEY",
|
|
94
|
+
"api_key is required. Get yours at https://app.replylayer.ai/connect",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
self._http = AsyncHttpClient(
|
|
98
|
+
api_key=api_key,
|
|
99
|
+
base_url=base_url.rstrip("/"),
|
|
100
|
+
max_retries=max_retries,
|
|
101
|
+
timeout=timeout,
|
|
102
|
+
max_retry_after_seconds=max_retry_after_seconds,
|
|
103
|
+
on_retry=on_retry,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
self.domains = AsyncDomains(self._http)
|
|
107
|
+
self.mailboxes = AsyncMailboxes(self._http)
|
|
108
|
+
self.messages = AsyncMessages(self._http)
|
|
109
|
+
self.drafts = AsyncDrafts(self._http)
|
|
110
|
+
self.threads = AsyncThreads(self._http)
|
|
111
|
+
self.attachments = AsyncAttachments(self._http)
|
|
112
|
+
self.webhooks = AsyncWebhooks(self._http)
|
|
113
|
+
self.recipients = AsyncRecipients(self._http)
|
|
114
|
+
self.suppressions = AsyncSuppressions(self._http)
|
|
115
|
+
self.inbound_blocklist = AsyncInboundBlocklist(self._http)
|
|
116
|
+
self.api_keys = AsyncApiKeys(self._http)
|
|
117
|
+
self.account = AsyncAccount(self._http)
|
|
118
|
+
self.health = AsyncHealth(self._http)
|
|
119
|
+
self.legal_holds = AsyncLegalHolds(self._http)
|
|
120
|
+
|
|
121
|
+
async def aclose(self) -> None:
|
|
122
|
+
await self._http.aclose()
|
|
123
|
+
|
|
124
|
+
async def __aenter__(self) -> Self:
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
async def __aexit__(self, *args: object) -> None:
|
|
128
|
+
await self.aclose()
|
replylayer/_http.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Awaitable, Callable, Union
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .errors import ReplyLayerError, error_from_response
|
|
11
|
+
|
|
12
|
+
_VERSION = "0.14.0"
|
|
13
|
+
_USER_AGENT = f"replylayer-sdk-py/{_VERSION}"
|
|
14
|
+
_PROTECTED_HEADER_KEYS = frozenset({"authorization", "content-type", "user-agent"})
|
|
15
|
+
|
|
16
|
+
# Default cap (seconds) on Retry-After honoring on 429. Raised from 60s
|
|
17
|
+
# (commit b) so the SDK can wait out the suppression-add hour-bucket rate limit
|
|
18
|
+
# (Retry-After ~3600s) instead of throwing immediately. Overridable per-client
|
|
19
|
+
# via ``max_retry_after_seconds``. Mirrors the TS SDK's MAX_RETRY_AFTER_MS
|
|
20
|
+
# (4_000_000 ms ~67 min).
|
|
21
|
+
_MAX_RETRY_AFTER_SECONDS = 4_000.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class RetryInfo:
|
|
26
|
+
"""Context passed to an ``on_retry`` hook before each retry sleep."""
|
|
27
|
+
|
|
28
|
+
attempt: int # 1-based: the attempt that just failed (1 = the first try)
|
|
29
|
+
error: ReplyLayerError # the error that triggered the retry
|
|
30
|
+
delay_seconds: float # the computed sleep before the next attempt (post-cap)
|
|
31
|
+
method: str # HTTP method
|
|
32
|
+
path: str # request path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Silent-by-default retry hook. Returns None, or (for the async client) an
|
|
36
|
+
# awaitable that is awaited. A throwing/rejecting callback is swallowed so it
|
|
37
|
+
# can never break the request loop.
|
|
38
|
+
OnRetry = Callable[[RetryInfo], Union[None, Awaitable[None]]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SyncHttpClient:
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
api_key: str,
|
|
45
|
+
base_url: str,
|
|
46
|
+
max_retries: int,
|
|
47
|
+
timeout: float,
|
|
48
|
+
max_retry_after_seconds: float = _MAX_RETRY_AFTER_SECONDS,
|
|
49
|
+
on_retry: OnRetry | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._api_key = api_key
|
|
52
|
+
self._base_url = base_url
|
|
53
|
+
self._max_retries = max_retries
|
|
54
|
+
self._timeout = timeout
|
|
55
|
+
self._max_retry_after_seconds = max_retry_after_seconds
|
|
56
|
+
self._on_retry = on_retry
|
|
57
|
+
self._client = httpx.Client(timeout=timeout)
|
|
58
|
+
|
|
59
|
+
def close(self) -> None:
|
|
60
|
+
self._client.close()
|
|
61
|
+
|
|
62
|
+
def request(
|
|
63
|
+
self,
|
|
64
|
+
method: str,
|
|
65
|
+
path: str,
|
|
66
|
+
*,
|
|
67
|
+
body: dict[str, Any] | None = None,
|
|
68
|
+
query: dict[str, str | None] | None = None,
|
|
69
|
+
no_auth: bool = False,
|
|
70
|
+
timeout: float | None = None,
|
|
71
|
+
extra_headers: dict[str, str] | None = None,
|
|
72
|
+
files: Any | None = None,
|
|
73
|
+
data: dict[str, Any] | None = None,
|
|
74
|
+
) -> Any:
|
|
75
|
+
# Migration 040 — extra_headers lets resources pass per-request
|
|
76
|
+
# headers (Idempotency-Key is the primary caller). Merged AFTER
|
|
77
|
+
# defaults so protected keys (Authorization, Content-Type,
|
|
78
|
+
# User-Agent) cannot be overridden by callers.
|
|
79
|
+
#
|
|
80
|
+
# Multipart uploads pass `files`/`data` (httpx builds the
|
|
81
|
+
# multipart/form-data body + boundary itself) — we must NOT set
|
|
82
|
+
# Content-Type ourselves, or the boundary is lost. Every other request
|
|
83
|
+
# is JSON.
|
|
84
|
+
is_multipart = files is not None
|
|
85
|
+
url = f"{self._base_url}{path}"
|
|
86
|
+
params = {k: v for k, v in (query or {}).items() if v is not None and v != ""}
|
|
87
|
+
headers = {"User-Agent": _USER_AGENT}
|
|
88
|
+
# Content-Type only with a body — Fastify rejects application/json + an
|
|
89
|
+
# empty body (FST_ERR_CTP_EMPTY_JSON_BODY), e.g. a bodyless DELETE.
|
|
90
|
+
if not is_multipart and body is not None:
|
|
91
|
+
headers["Content-Type"] = "application/json"
|
|
92
|
+
if not no_auth:
|
|
93
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
94
|
+
if extra_headers:
|
|
95
|
+
for k, v in extra_headers.items():
|
|
96
|
+
if k.lower() in _PROTECTED_HEADER_KEYS:
|
|
97
|
+
continue
|
|
98
|
+
headers[k] = v
|
|
99
|
+
|
|
100
|
+
send_kwargs: dict[str, Any] = {"params": params, "headers": headers}
|
|
101
|
+
if is_multipart:
|
|
102
|
+
send_kwargs["files"] = files
|
|
103
|
+
if data is not None:
|
|
104
|
+
send_kwargs["data"] = data
|
|
105
|
+
else:
|
|
106
|
+
send_kwargs["json"] = body
|
|
107
|
+
|
|
108
|
+
last_error: ReplyLayerError | None = None
|
|
109
|
+
for attempt in range(self._max_retries + 1):
|
|
110
|
+
try:
|
|
111
|
+
response = self._client.request(
|
|
112
|
+
method, url, **send_kwargs,
|
|
113
|
+
timeout=timeout or self._timeout,
|
|
114
|
+
)
|
|
115
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
116
|
+
raise ReplyLayerError(0, "NETWORK_ERROR", str(exc)) from exc
|
|
117
|
+
|
|
118
|
+
if response.is_success:
|
|
119
|
+
if response.status_code == 204:
|
|
120
|
+
return None
|
|
121
|
+
text = response.text
|
|
122
|
+
if not text:
|
|
123
|
+
return None
|
|
124
|
+
# Wrap a malformed 2xx body as ReplyLayerError(PARSE_ERROR)
|
|
125
|
+
# rather than leaking a raw JSONDecodeError (a ValueError
|
|
126
|
+
# subclass) — callers catch ReplyLayerError. Mirrors the TS SDK.
|
|
127
|
+
try:
|
|
128
|
+
return response.json()
|
|
129
|
+
except ValueError as exc:
|
|
130
|
+
raise ReplyLayerError(
|
|
131
|
+
response.status_code,
|
|
132
|
+
"PARSE_ERROR",
|
|
133
|
+
f"Failed to parse response: {text[:200]}",
|
|
134
|
+
) from exc
|
|
135
|
+
|
|
136
|
+
resp_headers = dict(response.headers)
|
|
137
|
+
try:
|
|
138
|
+
resp_body = response.json()
|
|
139
|
+
except Exception:
|
|
140
|
+
resp_body = {"error": f"HTTP {response.status_code}"}
|
|
141
|
+
|
|
142
|
+
last_error = error_from_response(response.status_code, resp_body, resp_headers)
|
|
143
|
+
|
|
144
|
+
# POST/PATCH/DELETE are mutating — their 5xx is NOT retried (a retry
|
|
145
|
+
# risks a double-send, or for DELETE a lost-mutation that retries
|
|
146
|
+
# into a confusing 404). 429 IS retried on every method (pre-dispatch
|
|
147
|
+
# gate rejection). No PUT verb exists in this SDK.
|
|
148
|
+
is_mutating = method in ("POST", "PATCH", "DELETE")
|
|
149
|
+
# Multipart uploads are never retried: a retry re-sends the same
|
|
150
|
+
# `files` object, which for a file-like (IO[bytes]) input is already
|
|
151
|
+
# consumed by the first attempt → an empty/truncated body or a
|
|
152
|
+
# misleading second error. Matches the CLI's no-retry posture +
|
|
153
|
+
# "never re-send a partial upload"; a 429 staging-quota surfaces to
|
|
154
|
+
# the caller immediately.
|
|
155
|
+
is_retryable = (not is_multipart) and (
|
|
156
|
+
response.status_code == 429 or (not is_mutating and response.status_code >= 500)
|
|
157
|
+
)
|
|
158
|
+
if not is_retryable or attempt == self._max_retries:
|
|
159
|
+
raise last_error
|
|
160
|
+
|
|
161
|
+
backoff = min(0.5 * (2 ** attempt), 30.0)
|
|
162
|
+
delay = backoff
|
|
163
|
+
if response.status_code == 429:
|
|
164
|
+
ra = resp_headers.get("retry-after", "")
|
|
165
|
+
if ra.isdigit() and int(ra) > 0:
|
|
166
|
+
delay = float(int(ra))
|
|
167
|
+
# Cap raised from 60s to ~67min (commit b) so the SDK can
|
|
168
|
+
# ride out an hour-bucket rate-limit (suppression-add
|
|
169
|
+
# returns Retry-After ~3600 when tripped). Without this,
|
|
170
|
+
# a 429 on a long bucket short-circuits to throw and the
|
|
171
|
+
# caller never retries.
|
|
172
|
+
if delay > self._max_retry_after_seconds:
|
|
173
|
+
raise last_error
|
|
174
|
+
|
|
175
|
+
# Silent-by-default retry hook. Guarded so a throwing callback can't
|
|
176
|
+
# break the request loop.
|
|
177
|
+
if self._on_retry is not None:
|
|
178
|
+
try:
|
|
179
|
+
self._on_retry(RetryInfo(attempt + 1, last_error, delay, method, path))
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
time.sleep(delay)
|
|
184
|
+
|
|
185
|
+
raise last_error or ReplyLayerError(0, "UNKNOWN", "Request failed")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class AsyncHttpClient:
|
|
189
|
+
def __init__(
|
|
190
|
+
self,
|
|
191
|
+
api_key: str,
|
|
192
|
+
base_url: str,
|
|
193
|
+
max_retries: int,
|
|
194
|
+
timeout: float,
|
|
195
|
+
max_retry_after_seconds: float = _MAX_RETRY_AFTER_SECONDS,
|
|
196
|
+
on_retry: OnRetry | None = None,
|
|
197
|
+
) -> None:
|
|
198
|
+
self._api_key = api_key
|
|
199
|
+
self._base_url = base_url
|
|
200
|
+
self._max_retries = max_retries
|
|
201
|
+
self._timeout = timeout
|
|
202
|
+
self._max_retry_after_seconds = max_retry_after_seconds
|
|
203
|
+
self._on_retry = on_retry
|
|
204
|
+
self._client = httpx.AsyncClient(timeout=timeout)
|
|
205
|
+
|
|
206
|
+
async def aclose(self) -> None:
|
|
207
|
+
await self._client.aclose()
|
|
208
|
+
|
|
209
|
+
async def request(
|
|
210
|
+
self,
|
|
211
|
+
method: str,
|
|
212
|
+
path: str,
|
|
213
|
+
*,
|
|
214
|
+
body: dict[str, Any] | None = None,
|
|
215
|
+
query: dict[str, str | None] | None = None,
|
|
216
|
+
no_auth: bool = False,
|
|
217
|
+
timeout: float | None = None,
|
|
218
|
+
extra_headers: dict[str, str] | None = None,
|
|
219
|
+
files: Any | None = None,
|
|
220
|
+
data: dict[str, Any] | None = None,
|
|
221
|
+
) -> Any:
|
|
222
|
+
# Migration 040 — see SyncHttpClient.request for the protected-key
|
|
223
|
+
# merge rationale and the multipart (files/data) handling. Keep the two
|
|
224
|
+
# implementations byte-identical.
|
|
225
|
+
is_multipart = files is not None
|
|
226
|
+
url = f"{self._base_url}{path}"
|
|
227
|
+
params = {k: v for k, v in (query or {}).items() if v is not None and v != ""}
|
|
228
|
+
headers = {"User-Agent": _USER_AGENT}
|
|
229
|
+
# Content-Type only with a body — Fastify rejects application/json + an
|
|
230
|
+
# empty body (FST_ERR_CTP_EMPTY_JSON_BODY), e.g. a bodyless DELETE.
|
|
231
|
+
if not is_multipart and body is not None:
|
|
232
|
+
headers["Content-Type"] = "application/json"
|
|
233
|
+
if not no_auth:
|
|
234
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
235
|
+
if extra_headers:
|
|
236
|
+
for k, v in extra_headers.items():
|
|
237
|
+
if k.lower() in _PROTECTED_HEADER_KEYS:
|
|
238
|
+
continue
|
|
239
|
+
headers[k] = v
|
|
240
|
+
|
|
241
|
+
send_kwargs: dict[str, Any] = {"params": params, "headers": headers}
|
|
242
|
+
if is_multipart:
|
|
243
|
+
send_kwargs["files"] = files
|
|
244
|
+
if data is not None:
|
|
245
|
+
send_kwargs["data"] = data
|
|
246
|
+
else:
|
|
247
|
+
send_kwargs["json"] = body
|
|
248
|
+
|
|
249
|
+
import asyncio
|
|
250
|
+
|
|
251
|
+
last_error: ReplyLayerError | None = None
|
|
252
|
+
for attempt in range(self._max_retries + 1):
|
|
253
|
+
try:
|
|
254
|
+
response = await self._client.request(
|
|
255
|
+
method, url, **send_kwargs,
|
|
256
|
+
timeout=timeout or self._timeout,
|
|
257
|
+
)
|
|
258
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
259
|
+
raise ReplyLayerError(0, "NETWORK_ERROR", str(exc)) from exc
|
|
260
|
+
|
|
261
|
+
if response.is_success:
|
|
262
|
+
if response.status_code == 204:
|
|
263
|
+
return None
|
|
264
|
+
text = response.text
|
|
265
|
+
if not text:
|
|
266
|
+
return None
|
|
267
|
+
# Wrap a malformed 2xx body as ReplyLayerError(PARSE_ERROR)
|
|
268
|
+
# rather than leaking a raw JSONDecodeError (a ValueError
|
|
269
|
+
# subclass) — callers catch ReplyLayerError. Mirrors the TS SDK.
|
|
270
|
+
try:
|
|
271
|
+
return response.json()
|
|
272
|
+
except ValueError as exc:
|
|
273
|
+
raise ReplyLayerError(
|
|
274
|
+
response.status_code,
|
|
275
|
+
"PARSE_ERROR",
|
|
276
|
+
f"Failed to parse response: {text[:200]}",
|
|
277
|
+
) from exc
|
|
278
|
+
|
|
279
|
+
resp_headers = dict(response.headers)
|
|
280
|
+
try:
|
|
281
|
+
resp_body = response.json()
|
|
282
|
+
except Exception:
|
|
283
|
+
resp_body = {"error": f"HTTP {response.status_code}"}
|
|
284
|
+
|
|
285
|
+
last_error = error_from_response(response.status_code, resp_body, resp_headers)
|
|
286
|
+
|
|
287
|
+
# POST/PATCH/DELETE are mutating — their 5xx is NOT retried (a retry
|
|
288
|
+
# risks a double-send, or for DELETE a lost-mutation that retries
|
|
289
|
+
# into a confusing 404). 429 IS retried on every method (pre-dispatch
|
|
290
|
+
# gate rejection). No PUT verb exists in this SDK.
|
|
291
|
+
is_mutating = method in ("POST", "PATCH", "DELETE")
|
|
292
|
+
# Multipart uploads are never retried: a retry re-sends the same
|
|
293
|
+
# `files` object, which for a file-like (IO[bytes]) input is already
|
|
294
|
+
# consumed by the first attempt → an empty/truncated body or a
|
|
295
|
+
# misleading second error. Matches the CLI's no-retry posture +
|
|
296
|
+
# "never re-send a partial upload"; a 429 staging-quota surfaces to
|
|
297
|
+
# the caller immediately.
|
|
298
|
+
is_retryable = (not is_multipart) and (
|
|
299
|
+
response.status_code == 429 or (not is_mutating and response.status_code >= 500)
|
|
300
|
+
)
|
|
301
|
+
if not is_retryable or attempt == self._max_retries:
|
|
302
|
+
raise last_error
|
|
303
|
+
|
|
304
|
+
backoff = min(0.5 * (2 ** attempt), 30.0)
|
|
305
|
+
delay = backoff
|
|
306
|
+
if response.status_code == 429:
|
|
307
|
+
ra = resp_headers.get("retry-after", "")
|
|
308
|
+
if ra.isdigit() and int(ra) > 0:
|
|
309
|
+
delay = float(int(ra))
|
|
310
|
+
# See SyncHttpClient comment — cap raised in commit b.
|
|
311
|
+
if delay > self._max_retry_after_seconds:
|
|
312
|
+
raise last_error
|
|
313
|
+
|
|
314
|
+
# Silent-by-default retry hook. Guarded so a throwing callback can't
|
|
315
|
+
# break the request loop; awaited if it returns an awaitable.
|
|
316
|
+
if self._on_retry is not None:
|
|
317
|
+
try:
|
|
318
|
+
result = self._on_retry(RetryInfo(attempt + 1, last_error, delay, method, path))
|
|
319
|
+
if inspect.isawaitable(result):
|
|
320
|
+
await result
|
|
321
|
+
except Exception:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
await asyncio.sleep(delay)
|
|
325
|
+
|
|
326
|
+
raise last_error or ReplyLayerError(0, "UNKNOWN", "Request failed")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, AsyncIterator, Callable, Awaitable, Iterator
|
|
4
|
+
|
|
5
|
+
from .types import Page
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SyncFetcher = Callable[[str | None], Page]
|
|
9
|
+
AsyncFetcher = Callable[[str | None], Awaitable[Page]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sync_auto_paginate(fetcher: SyncFetcher) -> Iterator[dict[str, Any]]:
|
|
13
|
+
cursor: str | None = None
|
|
14
|
+
first = True
|
|
15
|
+
while True:
|
|
16
|
+
page = fetcher(None if first else cursor)
|
|
17
|
+
first = False
|
|
18
|
+
yield from page.data
|
|
19
|
+
if not page.cursor:
|
|
20
|
+
break
|
|
21
|
+
cursor = page.cursor
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def async_auto_paginate(fetcher: AsyncFetcher) -> AsyncIterator[dict[str, Any]]:
|
|
25
|
+
cursor: str | None = None
|
|
26
|
+
first = True
|
|
27
|
+
while True:
|
|
28
|
+
page = await fetcher(None if first else cursor)
|
|
29
|
+
first = False
|
|
30
|
+
for item in page.data:
|
|
31
|
+
yield item
|
|
32
|
+
if not page.cursor:
|
|
33
|
+
break
|
|
34
|
+
cursor = page.cursor
|