broadcast-python 0.1.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.
@@ -0,0 +1,66 @@
1
+ """Python client for the Broadcast email platform.
2
+
3
+ from broadcast_python import Broadcast
4
+
5
+ client = Broadcast(api_token="...", host="https://mail.example.com")
6
+ client.subscribers.create(email="ada@example.com")
7
+
8
+ Works with any Broadcast instance — self-hosted or SaaS. No runtime
9
+ dependencies: the transport is built on the standard library's urllib.
10
+ """
11
+
12
+ from . import webhook
13
+ from .client import Broadcast
14
+ from .configuration import ENV_HOST, ENV_TOKEN, WARNINGS_MODES, Configuration
15
+ from .errors import (
16
+ APIError,
17
+ AuthenticationError,
18
+ AuthorizationError,
19
+ BroadcastError,
20
+ ConfigurationError,
21
+ ConflictError,
22
+ DeliveryError,
23
+ NotFoundError,
24
+ RateLimitError,
25
+ TimeoutError,
26
+ ValidationError,
27
+ WarningError,
28
+ )
29
+ from .resources.email_servers import REDACTED_FIELDS
30
+ from .resources.migration import COLLECTIONS
31
+ from .resources.transactionals import MAX_IDEMPOTENCY_KEY_LENGTH
32
+ from .response import RateLimit, Response, Warning_
33
+ from .version import VERSION
34
+ from .webhook import EVENT_TYPES
35
+
36
+ __version__ = VERSION
37
+
38
+ __all__ = [
39
+ "COLLECTIONS",
40
+ "ENV_HOST",
41
+ "ENV_TOKEN",
42
+ "EVENT_TYPES",
43
+ "MAX_IDEMPOTENCY_KEY_LENGTH",
44
+ "REDACTED_FIELDS",
45
+ "VERSION",
46
+ "WARNINGS_MODES",
47
+ "APIError",
48
+ "AuthenticationError",
49
+ "AuthorizationError",
50
+ "Broadcast",
51
+ "BroadcastError",
52
+ "Configuration",
53
+ "ConfigurationError",
54
+ "ConflictError",
55
+ "DeliveryError",
56
+ "NotFoundError",
57
+ "RateLimit",
58
+ "RateLimitError",
59
+ "Response",
60
+ "TimeoutError",
61
+ "ValidationError",
62
+ "WarningError",
63
+ "Warning_",
64
+ "__version__",
65
+ "webhook",
66
+ ]
@@ -0,0 +1,130 @@
1
+ from contextlib import contextmanager
2
+ from typing import Any, Dict, Iterator, Optional, Union
3
+
4
+ from .configuration import Configuration
5
+ from .connection import Connection
6
+ from .resources.autopilots import Autopilots
7
+ from .resources.broadcasts import Broadcasts
8
+ from .resources.discovery import Discovery
9
+ from .resources.email_servers import EmailServers
10
+ from .resources.migration import Migration
11
+ from .resources.opt_in_forms import OptInForms
12
+ from .resources.segments import Segments
13
+ from .resources.sequences import Sequences
14
+ from .resources.subscribers import Subscribers
15
+ from .resources.templates import Templates
16
+ from .resources.transactionals import Transactionals
17
+ from .resources.webhook_endpoints import WebhookEndpoints
18
+
19
+ Id = Union[str, int]
20
+
21
+
22
+ class Broadcast:
23
+ """Client for the Broadcast API.
24
+
25
+ client = Broadcast(api_token="...", host="https://mail.example.com")
26
+ client.subscribers.create(email="ada@example.com")
27
+ """
28
+
29
+ def __init__(self, **settings: Any):
30
+ self.config = Configuration(**settings)
31
+ self.config.validate()
32
+ self._connection = Connection(self.config)
33
+ self._channel_override: Optional[Id] = None
34
+
35
+ self.subscribers = Subscribers(self)
36
+ self.sequences = Sequences(self)
37
+ self.broadcasts = Broadcasts(self)
38
+ self.segments = Segments(self)
39
+ self.templates = Templates(self)
40
+ self.webhook_endpoints = WebhookEndpoints(self)
41
+ self.transactionals = Transactionals(self)
42
+ self.opt_in_forms = OptInForms(self)
43
+ self.email_servers = EmailServers(self)
44
+ self.autopilots = Autopilots(self)
45
+ self.discovery = Discovery(self)
46
+ #: Read-only export endpoints. Requires an admin (system) API token.
47
+ self.migration = Migration(self)
48
+
49
+ # --- Channel scoping (admin/system tokens) ---
50
+
51
+ @contextmanager
52
+ def with_channel(self, broadcast_channel_id: Id) -> Iterator["Broadcast"]:
53
+ """Scope every request inside the block to a channel.
54
+
55
+ with client.with_channel(123):
56
+ client.email_servers.list()
57
+
58
+ The previous scope is restored on exit, including when the block raises.
59
+ The override lives on the client instance, so concurrent use of the same
60
+ instance across threads will interleave — use one client per thread, or
61
+ pass ``broadcast_channel_id`` explicitly.
62
+ """
63
+ previous = self._channel_override
64
+ self._channel_override = broadcast_channel_id
65
+ try:
66
+ yield self
67
+ finally:
68
+ self._channel_override = previous
69
+
70
+ # --- Transactional email (convenience shims) ---
71
+
72
+ def send_email(
73
+ self,
74
+ to: str,
75
+ subject: Optional[str] = None,
76
+ body: Optional[str] = None,
77
+ reply_to: Optional[str] = None,
78
+ ) -> Any:
79
+ """Thin wrapper around ``transactionals.create``. Use that directly for
80
+ ``template_id``, ``double_opt_in``, ``preheader``, ``idempotency_key``."""
81
+ return self.transactionals.create(to=to, subject=subject, body=body, reply_to=reply_to)
82
+
83
+ def get_email(self, id: Id) -> Any: # noqa: A002
84
+ return self.transactionals.get(id)
85
+
86
+ # --- Discovery (convenience shims) ---
87
+
88
+ def whoami(self) -> Any:
89
+ return self.discovery.whoami()
90
+
91
+ def status(self) -> Any:
92
+ return self.discovery.status()
93
+
94
+ def prime(self) -> Any:
95
+ return self.discovery.prime()
96
+
97
+ def skill(self) -> str:
98
+ return self.discovery.skill()
99
+
100
+ # --- Internal ---
101
+
102
+ def request(
103
+ self,
104
+ method: str,
105
+ path: str,
106
+ body_or_params: Any = None,
107
+ headers: Optional[Dict[str, Any]] = None,
108
+ raw: bool = False,
109
+ ) -> Any:
110
+ payload = self._inject_channel_scope(body_or_params)
111
+ return self._connection.request(method, path, payload, headers=headers, raw=raw)
112
+
113
+ @property
114
+ def _active_channel_id(self) -> Optional[Id]:
115
+ if self._channel_override is not None:
116
+ return self._channel_override
117
+ return self.config.broadcast_channel_id
118
+
119
+ def _inject_channel_scope(self, body_or_params: Any) -> Any:
120
+ """Auto-include broadcast_channel_id when configured and not already set."""
121
+ channel_id = self._active_channel_id
122
+ if channel_id is None:
123
+ return body_or_params
124
+
125
+ payload = dict(body_or_params) if isinstance(body_or_params, dict) else {}
126
+ if payload.get("broadcast_channel_id") is not None:
127
+ return payload
128
+
129
+ payload["broadcast_channel_id"] = channel_id
130
+ return payload
@@ -0,0 +1,117 @@
1
+ """Client configuration and validation."""
2
+
3
+ import os
4
+ from typing import Any, Callable, Optional, Union
5
+
6
+ from .errors import ConfigurationError
7
+
8
+ #: How to handle the ``warnings`` array the API returns on successful writes.
9
+ #:
10
+ #: ``log`` — warn through ``logger`` if one is set (default)
11
+ #: ``raise`` — raise :class:`WarningError`; note the write already happened
12
+ #: ``ignore`` — leave them on the response for the caller to inspect
13
+ WARNINGS_MODES = ("log", "raise", "ignore")
14
+
15
+ #: Env vars use the same names as the Broadcast CLI's ``~/.config/broadcast/config``,
16
+ #: so a machine set up for the CLI can drive this client with no extra config.
17
+ ENV_HOST = "BROADCAST_HOST"
18
+ ENV_TOKEN = "BROADCAST_API_TOKEN"
19
+
20
+
21
+ class Configuration:
22
+ """Settings for a :class:`broadcast.client.Broadcast` instance.
23
+
24
+ Durations are in **seconds**, matching the Ruby gem.
25
+ """
26
+
27
+ __slots__ = (
28
+ "api_token",
29
+ "broadcast_channel_id",
30
+ "debug",
31
+ "host",
32
+ "logger",
33
+ "max_retry_delay",
34
+ "open_timeout",
35
+ "opener",
36
+ "retry_attempts",
37
+ "retry_delay",
38
+ "sleep",
39
+ "timeout",
40
+ "warnings_mode",
41
+ )
42
+
43
+ def __init__(
44
+ self,
45
+ api_token: Optional[str] = None,
46
+ host: Optional[str] = None,
47
+ timeout: int = 30,
48
+ open_timeout: int = 10,
49
+ retry_attempts: int = 3,
50
+ retry_delay: float = 1,
51
+ max_retry_delay: float = 30,
52
+ warnings_mode: str = "log",
53
+ logger: Any = None,
54
+ debug: bool = False,
55
+ broadcast_channel_id: Optional[Union[str, int]] = None,
56
+ opener: Any = None,
57
+ sleep: Optional[Callable[[float], None]] = None,
58
+ ):
59
+ self.api_token = api_token if api_token is not None else os.environ.get(ENV_TOKEN)
60
+ # No default host. Broadcast is self-hosted-first — every instance lives
61
+ # at its own domain, so any built-in guess is wrong for nearly everyone.
62
+ self.host = host if host is not None else os.environ.get(ENV_HOST)
63
+
64
+ self.timeout = timeout
65
+ self.open_timeout = open_timeout
66
+ self.retry_attempts = retry_attempts
67
+ self.retry_delay = retry_delay
68
+ # Ceiling for a server-supplied Retry-After. Without it a long
69
+ # rate-limit window would block the caller for as long as the server asked.
70
+ self.max_retry_delay = max_retry_delay
71
+
72
+ self.warnings_mode = warnings_mode
73
+ self.logger = logger
74
+ self.debug = debug
75
+ self.broadcast_channel_id = broadcast_channel_id
76
+
77
+ # Injectable for tests, so the suite neither opens sockets nor sleeps.
78
+ self.opener = opener
79
+ self.sleep = sleep
80
+
81
+ def validate(self) -> None:
82
+ if _blank(self.api_token):
83
+ raise ConfigurationError("api_token is required")
84
+ if _blank(self.host):
85
+ raise ConfigurationError(_host_missing_message())
86
+
87
+ self.host = str(self.host).strip().rstrip("/")
88
+ self._validate_host_scheme()
89
+ self._validate_warnings_mode()
90
+
91
+ def _validate_host_scheme(self) -> None:
92
+ if str(self.host).startswith(("http://", "https://")):
93
+ return
94
+ raise ConfigurationError(
95
+ "host must include a scheme (http:// or https://), got {!r}".format(self.host)
96
+ )
97
+
98
+ def _validate_warnings_mode(self) -> None:
99
+ if self.warnings_mode in WARNINGS_MODES:
100
+ return
101
+ raise ConfigurationError(
102
+ "warnings_mode must be one of {}, got {!r}".format(
103
+ ", ".join(WARNINGS_MODES), self.warnings_mode
104
+ )
105
+ )
106
+
107
+
108
+ def _blank(value: Any) -> bool:
109
+ return value is None or str(value).strip() == ""
110
+
111
+
112
+ def _host_missing_message() -> str:
113
+ return (
114
+ "host is required — point it at your Broadcast instance, e.g. "
115
+ "Broadcast(api_token='...', host='https://mail.example.com'). "
116
+ "You can also set the {} environment variable.".format(ENV_HOST)
117
+ )
@@ -0,0 +1,384 @@
1
+ """HTTP transport.
2
+
3
+ Owns request building, response/error mapping, retries, redirects, and warning
4
+ dispatch, so ``Broadcast`` stays a thin facade over configuration and resources.
5
+
6
+ Built on ``urllib`` from the standard library so the package has no runtime
7
+ dependencies and cannot conflict with a pinned ``requests`` or ``httpx``
8
+ elsewhere in the environment.
9
+ """
10
+
11
+ import contextlib
12
+ import json
13
+ import socket
14
+ import time
15
+ import urllib.request
16
+ from typing import Any, Dict, List, Optional, Tuple
17
+ from urllib.error import HTTPError, URLError
18
+ from urllib.parse import urlencode, urljoin, urlparse
19
+
20
+ from .configuration import Configuration
21
+ from .errors import (
22
+ APIError,
23
+ AuthenticationError,
24
+ AuthorizationError,
25
+ ConflictError,
26
+ NotFoundError,
27
+ RateLimitError,
28
+ TimeoutError,
29
+ ValidationError,
30
+ WarningError,
31
+ )
32
+ from .response import build_response
33
+ from .version import VERSION
34
+
35
+ MAX_REDIRECTS = 3
36
+ REDIRECT_CODES = (301, 302, 307, 308)
37
+
38
+ ERROR_MAPPING = {
39
+ 401: (AuthenticationError, "Authentication failed"),
40
+ 403: (AuthorizationError, "Not authorized"),
41
+ 404: (NotFoundError, "Resource not found"),
42
+ 409: (ConflictError, "A request with this Idempotency-Key is still being processed"),
43
+ 422: (ValidationError, "Validation failed"),
44
+ }
45
+
46
+
47
+ class _NoRedirects(urllib.request.HTTPRedirectHandler):
48
+ """Stops urllib following redirects on our behalf.
49
+
50
+ Every request carries ``Authorization: Bearer <token>``. urllib's default
51
+ handler would follow a redirect to any host and take the token with it.
52
+ """
53
+
54
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
55
+ return None
56
+
57
+
58
+ class Connection:
59
+ def __init__(self, config: Configuration):
60
+ self.config = config
61
+
62
+ def request(
63
+ self,
64
+ method: str,
65
+ path: str,
66
+ payload: Any = None,
67
+ headers: Optional[Dict[str, Any]] = None,
68
+ raw: bool = False,
69
+ ) -> Any:
70
+ url = self._build_url(path, method, payload)
71
+ return self._retry_with_backoff(
72
+ lambda: self._execute(method, url, payload, headers or {}, raw, 0)
73
+ )
74
+
75
+ # --- Request building ---------------------------------------------------
76
+
77
+ def _build_url(self, path: str, method: str, payload: Any) -> str:
78
+ url = "{}{}".format(self.config.host, path)
79
+ if method == "GET" and _present(payload):
80
+ url = "{}?{}".format(url, urlencode(_flatten_params(payload)))
81
+ return url
82
+
83
+ def _execute(
84
+ self, method: str, url: str, payload: Any, extra_headers: Dict[str, Any], raw: bool, redirects: int
85
+ ) -> Any:
86
+ body = None
87
+ if method != "GET" and _present(payload):
88
+ body = json.dumps(payload).encode("utf-8")
89
+
90
+ request = urllib.request.Request(url, data=body, method=method)
91
+ request.add_header("Authorization", "Bearer {}".format(self.config.api_token))
92
+ request.add_header("Content-Type", "application/json")
93
+ request.add_header("User-Agent", "broadcast-python/{}".format(VERSION))
94
+ for key, value in (extra_headers or {}).items():
95
+ if value is None:
96
+ continue
97
+ request.add_header(str(key), str(value))
98
+
99
+ self._debug_request(method, url, body)
100
+
101
+ opener = self.config.opener or _default_opener()
102
+ try:
103
+ response = opener.open(request, timeout=self.config.timeout)
104
+ except HTTPError as error:
105
+ status = error.code
106
+ headers = dict(error.headers.items()) if error.headers else {}
107
+ if status in REDIRECT_CODES:
108
+ return self._follow_redirect(
109
+ headers, status, method, url, extra_headers, raw, redirects
110
+ )
111
+ self._raise_for_status(status, _safe_read(error), headers)
112
+ raise # unreachable; _raise_for_status always raises
113
+ except URLError as error:
114
+ raise _transport_error(error) from error
115
+ except socket.timeout as error:
116
+ raise TimeoutError("Request timeout: {}".format(error)) from error
117
+
118
+ # urllib exposes the code as `.status` on 3.9+ and `.code` on older
119
+ # objects. Both are Any to the type checker, so the fallback is spelled
120
+ # out rather than chained through `or`, which loses the int.
121
+ raw_status = getattr(response, "status", None)
122
+ if raw_status is None:
123
+ raw_status = getattr(response, "code", None)
124
+ status = int(raw_status) if raw_status is not None else 200
125
+ headers = _headers_of(response)
126
+ payload_bytes = response.read()
127
+ self._debug_response(status)
128
+
129
+ if status in REDIRECT_CODES:
130
+ return self._follow_redirect(headers, status, method, url, extra_headers, raw, redirects)
131
+
132
+ return self._build_success(status, payload_bytes, headers, raw)
133
+
134
+ # --- Redirects ----------------------------------------------------------
135
+ #
136
+ # A redirect nearly always means a misconfigured `host` (http vs https, a
137
+ # bare apex that redirects to www, a stale domain). Two things are never
138
+ # followed: writes, because replaying a send against an unexpected origin is
139
+ # worse than failing; and anything that changes host, because the request
140
+ # carries the API token.
141
+
142
+ def _follow_redirect(self, headers, status, method, url, extra_headers, raw, redirects):
143
+ location = _header(headers, "location")
144
+
145
+ if method != "GET":
146
+ raise APIError(
147
+ "Host redirected {} {} to {}. Set `host` to the final URL — "
148
+ "writes are not followed automatically.".format(
149
+ method, url, location or "(no Location header)"
150
+ )
151
+ )
152
+ if location is None:
153
+ raise APIError("Redirect from {} had no Location header".format(url))
154
+ if redirects >= MAX_REDIRECTS:
155
+ raise APIError("Too many redirects ({}) starting at {}".format(MAX_REDIRECTS, url))
156
+
157
+ target = urljoin(url, location)
158
+ if (urlparse(target).hostname or "").lower() != (urlparse(url).hostname or "").lower():
159
+ raise APIError(
160
+ "Host redirected {} to a different host ({}). Not following it — "
161
+ "the request carries your API token. Set `host` to the correct "
162
+ "instance URL.".format(url, target)
163
+ )
164
+
165
+ # The query string is already baked into the current URL.
166
+ return self._execute("GET", target, None, extra_headers, raw, redirects + 1)
167
+
168
+ # --- Responses ----------------------------------------------------------
169
+
170
+ def _build_success(self, status: int, payload: bytes, headers: Dict[str, str], raw: bool) -> Any:
171
+ if raw:
172
+ return _raw_body(payload, headers)
173
+
174
+ result = build_response(_parse_success_body(payload), status, headers)
175
+ self._handle_warnings(result)
176
+ return result
177
+
178
+ def _raise_for_status(self, status: int, payload: bytes, headers: Dict[str, str]) -> None:
179
+ message = _parse_error(payload)
180
+
181
+ if status == 429:
182
+ retry_after = _header(headers, "retry-after")
183
+ raise RateLimitError(
184
+ message or "Rate limit exceeded",
185
+ retry_after=int(retry_after) if retry_after and str(retry_after).isdigit() else None,
186
+ )
187
+
188
+ mapping = ERROR_MAPPING.get(status)
189
+ if mapping:
190
+ error_class, default = mapping
191
+ raise error_class(message or default)
192
+
193
+ if status >= 500:
194
+ raise APIError(message or "Server error ({})".format(status))
195
+
196
+ raise APIError(message or "Unexpected response: {}".format(status))
197
+
198
+ def _handle_warnings(self, result: Any) -> None:
199
+ warnings = getattr(result, "warnings", None)
200
+ if not warnings:
201
+ return
202
+
203
+ if self.config.warnings_mode == "raise":
204
+ raise WarningError(warnings, result)
205
+ if self.config.warnings_mode == "log" and self.config.logger is not None:
206
+ for warning in warnings:
207
+ self.config.logger.warning("[broadcast] {}".format(warning))
208
+
209
+ # --- Retries ------------------------------------------------------------
210
+
211
+ def _retry_with_backoff(self, operation):
212
+ attempts = 0
213
+ while True:
214
+ attempts += 1
215
+ try:
216
+ return operation()
217
+ except Exception as error:
218
+ if attempts >= self.config.retry_attempts or not _retryable(error):
219
+ raise
220
+ self._sleep(self._delay_for(error, attempts))
221
+
222
+ def _delay_for(self, error: Exception, attempts: int) -> float:
223
+ """Honour Retry-After, but never sleep longer than max_retry_delay."""
224
+ if isinstance(error, RateLimitError) and error.retry_after is not None:
225
+ return min(error.retry_after, self.config.max_retry_delay)
226
+ return min(self.config.retry_delay * attempts, self.config.max_retry_delay)
227
+
228
+ def _sleep(self, seconds: float) -> None:
229
+ (self.config.sleep or time.sleep)(seconds)
230
+
231
+ # --- Debug logging ------------------------------------------------------
232
+
233
+ def _debug_request(self, method: str, url: str, body: Optional[bytes]) -> None:
234
+ if not self.config.debug or self.config.logger is None:
235
+ return
236
+ # Never log the Authorization header or the body: bodies carry
237
+ # subscriber email addresses and credential fields.
238
+ self.config.logger.debug(
239
+ "[broadcast] -> {} {}{}".format(method, url, " (body redacted)" if body else "")
240
+ )
241
+
242
+ def _debug_response(self, status: int) -> None:
243
+ if not self.config.debug or self.config.logger is None:
244
+ return
245
+ self.config.logger.debug("[broadcast] <- {}".format(status))
246
+
247
+
248
+ # --- Helpers ---------------------------------------------------------------
249
+
250
+
251
+ def _default_opener():
252
+ return urllib.request.build_opener(_NoRedirects)
253
+
254
+
255
+ def _present(payload: Any) -> bool:
256
+ return isinstance(payload, dict) and len(payload) > 0
257
+
258
+
259
+ def _flatten_params(params: Dict[str, Any]) -> List[Tuple[str, str]]:
260
+ """Arrays repeat as ``key[]``, dicts flatten to ``key[sub]``.
261
+
262
+ Booleans are lowercased: Python's ``str(True)`` is ``"True"``, which Rails
263
+ does not read as true.
264
+ """
265
+ result: List[Tuple[str, str]] = []
266
+
267
+ for key, value in params.items():
268
+ if value is None:
269
+ continue
270
+ if isinstance(value, (list, tuple)):
271
+ result.extend(("{}[]".format(key), _stringify(v)) for v in value)
272
+ elif isinstance(value, dict):
273
+ result.extend(("{}[{}]".format(key, k), _stringify(v)) for k, v in value.items())
274
+ else:
275
+ result.append((str(key), _stringify(value)))
276
+
277
+ return result
278
+
279
+
280
+ def _stringify(value: Any) -> str:
281
+ if isinstance(value, bool):
282
+ return "true" if value else "false"
283
+ return str(value)
284
+
285
+
286
+ def _raw_body(payload: bytes, headers: Dict[str, str]) -> Any:
287
+ """Raw endpoints serve text (/api/v1/skill) and binary file assets alike.
288
+
289
+ Decoding a PNG as text would corrupt it, so only decode when the server
290
+ actually declared a charset.
291
+ """
292
+ content_type = _header(headers, "content-type") or ""
293
+ if "charset=" in content_type.lower():
294
+ return payload.decode("utf-8", errors="replace")
295
+ return payload
296
+
297
+
298
+ def _parse_success_body(payload: bytes) -> Any:
299
+ text = payload.decode("utf-8", errors="replace").strip() if payload else ""
300
+ if text == "":
301
+ return {}
302
+ try:
303
+ return json.loads(text)
304
+ except ValueError:
305
+ # A 2xx that isn't JSON (an HTML error page from a proxy, say). Surface
306
+ # it as an empty body rather than exploding — raw=True is the deliberate
307
+ # way to read non-JSON endpoints.
308
+ return {}
309
+
310
+
311
+ def _parse_error(payload: bytes) -> Optional[str]:
312
+ try:
313
+ body = json.loads(payload.decode("utf-8", errors="replace"))
314
+ except (ValueError, AttributeError):
315
+ return None
316
+ if not isinstance(body, dict):
317
+ return None
318
+
319
+ if isinstance(body.get("error"), str):
320
+ return body["error"]
321
+ return _format_errors(body.get("errors"))
322
+
323
+
324
+ def _format_errors(errors: Any) -> Optional[str]:
325
+ """ActiveModel errors arrive as ``{"field": ["msg", ...]}``."""
326
+ if errors is None:
327
+ return None
328
+ if isinstance(errors, list):
329
+ return ", ".join(str(e) for e in errors)
330
+ if not isinstance(errors, dict):
331
+ return None
332
+
333
+ parts = []
334
+ for field, messages in errors.items():
335
+ if not isinstance(messages, (list, tuple)):
336
+ messages = [messages]
337
+ parts.append("{} {}".format(field, ", ".join(str(m) for m in messages)))
338
+ return "; ".join(parts)
339
+
340
+
341
+ def _headers_of(response: Any) -> Dict[str, str]:
342
+ headers = getattr(response, "headers", {}) or {}
343
+ try:
344
+ return dict(headers.items())
345
+ except AttributeError:
346
+ return dict(headers)
347
+
348
+
349
+ def _header(headers: Dict[str, str], name: str) -> Optional[str]:
350
+ for key, value in (headers or {}).items():
351
+ if str(key).lower() == name:
352
+ return value
353
+ return None
354
+
355
+
356
+ def _safe_read(error: HTTPError) -> bytes:
357
+ """Read and close an error body.
358
+
359
+ HTTPError is a file-like object; leaving it open leaks the underlying
360
+ socket and raises ResourceWarning under -W error.
361
+ """
362
+ try:
363
+ return error.read()
364
+ except Exception:
365
+ return b""
366
+ finally:
367
+ with contextlib.suppress(Exception):
368
+ error.close()
369
+
370
+
371
+ def _transport_error(error: URLError) -> Exception:
372
+ """DNS/TCP/TLS failures and timeouts alike arrive as URLError.
373
+
374
+ They are transient often enough to be worth a retry, and TimeoutError is the
375
+ class the retry loop honours.
376
+ """
377
+ return TimeoutError("Request timeout: {}".format(error.reason if hasattr(error, "reason") else error))
378
+
379
+
380
+ def _retryable(error: Exception) -> bool:
381
+ if isinstance(error, (TimeoutError, RateLimitError)):
382
+ return True
383
+ # Only 5xx. A 422 is deterministic — retrying it is pure latency.
384
+ return isinstance(error, APIError) and "Server error" in str(error)