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 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