mobilevalidate-sdk 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.
@@ -0,0 +1,42 @@
1
+ """Public constants: the sandbox key and the documented test values (magic numbers and e-mail addresses)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict
6
+
7
+ __version__ = "1.0.0"
8
+
9
+ DEFAULT_BASE_URL = "https://api.mobilevalidate.com"
10
+
11
+ #: Public sandbox key — public by design. It answers ONLY the documented test values below (any other input is
12
+ #: refused with ``sandbox_magic_only``), is limited per client IP, never billed and never reaches a network.
13
+ SANDBOX_PUBLIC_KEY = "mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym"
14
+
15
+ #: Server-enforced sandbox limits (per client IP; the key is shared by everyone).
16
+ SANDBOX_LIMITS: Dict[str, int] = {"per_minute": 30, "per_day": 1000, "max_job_rows": 10}
17
+
18
+ #: Magic phone numbers (E.164) → documented answer in test mode (sandbox key and personal test keys).
19
+ TEST_NUMBERS: Dict[str, str] = {
20
+ # Magic test numbers: the same answer for every phone service, with any test key (including the sandbox key).
21
+ "registered": "+447700900001",
22
+ "not_registered": "+447700900002",
23
+ "unknown": "+447700900003", # reason UPSTREAM_TIMEOUT
24
+ "pending": "+447700900004", # pending for about 5 s, then registered
25
+ "unsupported_country": "+447700900005",
26
+ "business": "+447700900006", # registered; a business account for whatsapp.business
27
+ "rate_limited": "+447700900429", # the request fails with rate_limited
28
+ "insufficient_balance": "+447700900402", # the request fails with insufficient_balance
29
+ }
30
+
31
+ TEST_EMAIL_DOMAIN = "test.mobilevalidate.com"
32
+
33
+ TEST_EMAILS: Dict[str, str] = {
34
+ # Magic test e-mail addresses: the same answer for every e-mail service.
35
+ "registered": "registered@test.mobilevalidate.com",
36
+ "not_registered": "not-registered@test.mobilevalidate.com",
37
+ "unknown": "unknown@test.mobilevalidate.com", # reason UPSTREAM_TIMEOUT
38
+ "pending": "pending@test.mobilevalidate.com", # pending for about 5 s, then registered
39
+ "unsupported": "unsupported@test.mobilevalidate.com", # unknown, reason UNSUPPORTED_PROVIDER
40
+ "rate_limited": "rate-limited@test.mobilevalidate.com", # the request fails with rate_limited
41
+ "insufficient_balance": "no-balance@test.mobilevalidate.com", # the request fails with insufficient_balance
42
+ }
@@ -0,0 +1,364 @@
1
+ """Typed errors. Every API error code has its own class; all derive from :class:`MobileValidateError`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import email.utils
6
+ import time
7
+ from typing import Any, Dict, Mapping, Optional, Type
8
+
9
+ __all__ = [
10
+ "MobileValidateError",
11
+ "APIError",
12
+ "InvalidRequestError",
13
+ "TooManyNumbersError",
14
+ "TestNumberOnlyError",
15
+ "InvalidCursorError",
16
+ "UnauthorizedError",
17
+ "AuthenticationError",
18
+ "InsufficientBalanceError",
19
+ "CostLimitExceededError",
20
+ "InsufficientScopeError",
21
+ "ServiceDisabledError",
22
+ "SandboxMagicOnlyError",
23
+ "SuspectedEnumerationError",
24
+ "NotFoundError",
25
+ "IdempotencyKeyReusedError",
26
+ "IdempotencyRequestInProgressError",
27
+ "TestKeyExistsError",
28
+ "PayloadTooLargeError",
29
+ "RateLimitedError",
30
+ "RateLimitError",
31
+ "DailyCapReachedError",
32
+ "SpendCapReachedError",
33
+ "InternalServerError",
34
+ "TemporarilyUnavailableError",
35
+ "InvalidResponseError",
36
+ "APIConnectionError",
37
+ "APITimeoutError",
38
+ "MissingApiKeyError",
39
+ "InvalidArgumentError",
40
+ "WebhookVerificationError",
41
+ ]
42
+
43
+ # Retryability per code, used when the error body does not say.
44
+ _RETRYABLE = {
45
+ "idempotency_request_in_progress": True,
46
+ "rate_limited": True,
47
+ "daily_cap_reached": True,
48
+ "internal_error": True,
49
+ "temporarily_unavailable": True,
50
+ "connection_error": True,
51
+ "timeout": True,
52
+ }
53
+
54
+
55
+ class MobileValidateError(Exception):
56
+ """Base class of every error raised by the SDK (except :class:`WebhookVerificationError`)."""
57
+
58
+ default_code = "error"
59
+
60
+ def __init__(
61
+ self,
62
+ message: str,
63
+ *,
64
+ code: Optional[str] = None,
65
+ status: Optional[int] = None,
66
+ retryable: Optional[bool] = None,
67
+ request_id: Optional[str] = None,
68
+ param: Optional[str] = None,
69
+ doc_url: Optional[str] = None,
70
+ suggestion: Optional[str] = None,
71
+ retry_after: Optional[float] = None,
72
+ body: Any = None,
73
+ ) -> None:
74
+ super().__init__(message)
75
+ self.message = message
76
+ self.code: str = code or self.default_code
77
+ #: HTTP status, or ``None`` when no response was received.
78
+ self.status = status
79
+ self.retryable: bool = retryable if retryable is not None else _RETRYABLE.get(self.code, False)
80
+ self.request_id = request_id
81
+ self.param = param
82
+ self.doc_url = doc_url
83
+ #: Plain-English hint on how to fix the request (may be ``None``).
84
+ self.suggestion = suggestion
85
+ #: Server ``Retry-After`` hint in seconds (may be ``None``).
86
+ self.retry_after = retry_after
87
+ self.body = body
88
+
89
+ def __str__(self) -> str:
90
+ s = f"{self.code}: {self.message}"
91
+ if self.suggestion:
92
+ s += f" (suggestion: {self.suggestion})"
93
+ if self.request_id:
94
+ s += f" [request {self.request_id}]"
95
+ return s
96
+
97
+ def __repr__(self) -> str:
98
+ return f"{type(self).__name__}(code={self.code!r}, status={self.status!r}, message={self.message!r})"
99
+
100
+ def to_dict(self) -> Dict[str, Any]:
101
+ return {
102
+ "code": self.code,
103
+ "message": self.message,
104
+ "status": self.status,
105
+ "retryable": self.retryable,
106
+ "request_id": self.request_id,
107
+ "param": self.param,
108
+ "doc_url": self.doc_url,
109
+ "suggestion": self.suggestion,
110
+ }
111
+
112
+
113
+ class APIError(MobileValidateError):
114
+ """The API answered with an error response. Unknown codes are raised as this class."""
115
+
116
+ default_code = "api_error"
117
+
118
+
119
+
120
+ class InvalidRequestError(APIError):
121
+ """400 invalid_request: the request is malformed or a parameter is invalid (see ``param``)."""
122
+
123
+ default_code = "invalid_request"
124
+
125
+
126
+ class TooManyNumbersError(APIError):
127
+ """400 too_many_numbers: too many identifiers in one request."""
128
+
129
+ default_code = "too_many_numbers"
130
+
131
+
132
+ class TestNumberOnlyError(APIError):
133
+ """400 test_number_only: test values used with a live key (or vice versa)."""
134
+
135
+ __test__ = False # not a pytest test class
136
+ default_code = "test_number_only"
137
+
138
+
139
+ class InvalidCursorError(APIError):
140
+ """400 invalid_cursor: the pagination cursor is invalid or expired."""
141
+
142
+ default_code = "invalid_cursor"
143
+
144
+
145
+ class UnauthorizedError(APIError):
146
+ """401 unauthorized: missing, malformed or revoked API key."""
147
+
148
+ default_code = "unauthorized"
149
+
150
+
151
+ AuthenticationError = UnauthorizedError
152
+
153
+
154
+ class InsufficientBalanceError(APIError):
155
+ """402 insufficient_balance: not enough credit for this request."""
156
+
157
+ default_code = "insufficient_balance"
158
+
159
+
160
+ class CostLimitExceededError(APIError):
161
+ """402 cost_limit_exceeded: the maximum possible cost is above ``max_cost``."""
162
+
163
+ default_code = "cost_limit_exceeded"
164
+
165
+
166
+ class InsufficientScopeError(APIError):
167
+ """403 insufficient_scope: the key may not use this endpoint."""
168
+
169
+ default_code = "insufficient_scope"
170
+
171
+
172
+ class ServiceDisabledError(APIError):
173
+ """403 service_disabled: a requested check is not available (or bulk only)."""
174
+
175
+ default_code = "service_disabled"
176
+
177
+
178
+ class SandboxMagicOnlyError(APIError):
179
+ """403 sandbox_magic_only: the public sandbox key only answers the documented test values."""
180
+
181
+ default_code = "sandbox_magic_only"
182
+
183
+
184
+ class SuspectedEnumerationError(APIError):
185
+ """403 suspected_enumeration: the input looks like a generated list."""
186
+
187
+ default_code = "suspected_enumeration"
188
+
189
+
190
+ class NotFoundError(APIError):
191
+ """404 not_found: unknown resource or route."""
192
+
193
+ default_code = "not_found"
194
+
195
+
196
+ class IdempotencyKeyReusedError(APIError):
197
+ """409 idempotency_key_reused: the key was used with a different request body."""
198
+
199
+ default_code = "idempotency_key_reused"
200
+
201
+
202
+ class IdempotencyRequestInProgressError(APIError):
203
+ """409 idempotency_request_in_progress: the same request is still running (retryable)."""
204
+
205
+ default_code = "idempotency_request_in_progress"
206
+
207
+
208
+ class TestKeyExistsError(APIError):
209
+ """409 test_key_exists: this e-mail address already has an active test key."""
210
+
211
+ __test__ = False
212
+ default_code = "test_key_exists"
213
+
214
+
215
+ class PayloadTooLargeError(APIError):
216
+ """413 payload_too_large."""
217
+
218
+ default_code = "payload_too_large"
219
+
220
+
221
+ class RateLimitedError(APIError):
222
+ """429 rate_limited: slow down (retryable; honours Retry-After)."""
223
+
224
+ default_code = "rate_limited"
225
+
226
+
227
+ RateLimitError = RateLimitedError
228
+
229
+
230
+ class DailyCapReachedError(APIError):
231
+ """429 daily_cap_reached."""
232
+
233
+ default_code = "daily_cap_reached"
234
+
235
+
236
+ class SpendCapReachedError(APIError):
237
+ """429 spend_cap_reached: the key's spend cap is reached (not retryable)."""
238
+
239
+ default_code = "spend_cap_reached"
240
+
241
+
242
+ class InternalServerError(APIError):
243
+ """500 internal_error (retryable)."""
244
+
245
+ default_code = "internal_error"
246
+
247
+
248
+ class TemporarilyUnavailableError(APIError):
249
+ """503 temporarily_unavailable (retryable)."""
250
+
251
+ default_code = "temporarily_unavailable"
252
+
253
+
254
+ class InvalidResponseError(APIError):
255
+ """The response was not valid JSON."""
256
+
257
+ default_code = "invalid_response"
258
+
259
+
260
+ class APIConnectionError(MobileValidateError):
261
+ """No usable response: network failure (retryable)."""
262
+
263
+ default_code = "connection_error"
264
+
265
+
266
+ class APITimeoutError(APIConnectionError):
267
+ """The request timed out (retryable)."""
268
+
269
+ default_code = "timeout"
270
+
271
+
272
+ class MissingApiKeyError(MobileValidateError):
273
+ """No API key: pass ``api_key=``, set ``MOBILEVALIDATE_API_KEY`` or use ``sandbox=True``."""
274
+
275
+ default_code = "missing_api_key"
276
+
277
+
278
+ class InvalidArgumentError(MobileValidateError):
279
+ """An argument was rejected by the SDK before any request was sent."""
280
+
281
+ default_code = "invalid_argument"
282
+
283
+
284
+ class WebhookVerificationError(Exception):
285
+ """A webhook signature, timestamp or body could not be verified."""
286
+
287
+
288
+ ERROR_CLASSES: Dict[str, Type[MobileValidateError]] = {
289
+ c.default_code: c
290
+ for c in (
291
+ InvalidRequestError, TooManyNumbersError, TestNumberOnlyError, InvalidCursorError, UnauthorizedError,
292
+ InsufficientBalanceError, CostLimitExceededError, InsufficientScopeError, ServiceDisabledError,
293
+ SandboxMagicOnlyError, SuspectedEnumerationError, NotFoundError, IdempotencyKeyReusedError,
294
+ IdempotencyRequestInProgressError, TestKeyExistsError, PayloadTooLargeError, RateLimitedError,
295
+ DailyCapReachedError, SpendCapReachedError, InternalServerError, TemporarilyUnavailableError,
296
+ InvalidResponseError, APIConnectionError, APITimeoutError, MissingApiKeyError, InvalidArgumentError,
297
+ )
298
+ }
299
+
300
+
301
+ def _code_for_status(status: int) -> str:
302
+ if status == 400:
303
+ return "invalid_request"
304
+ if status == 401:
305
+ return "unauthorized"
306
+ if status == 403:
307
+ return "insufficient_scope"
308
+ if status == 404:
309
+ return "not_found"
310
+ if status == 413:
311
+ return "payload_too_large"
312
+ if status == 429:
313
+ return "rate_limited"
314
+ if status == 503:
315
+ return "temporarily_unavailable"
316
+ return "internal_error" if status >= 500 else "invalid_request"
317
+
318
+
319
+ def parse_retry_after(value: Optional[str]) -> Optional[float]:
320
+ """Retry-After as seconds or an HTTP date → seconds (``None`` if absent/invalid)."""
321
+ if not value:
322
+ return None
323
+ try:
324
+ return max(0.0, float(value))
325
+ except ValueError:
326
+ pass
327
+ try:
328
+ dt = email.utils.parsedate_to_datetime(value)
329
+ except (TypeError, ValueError):
330
+ return None
331
+ if dt is None:
332
+ return None
333
+ return max(0.0, dt.timestamp() - time.time())
334
+
335
+
336
+ def make_error(code: str, message: str, **kw: Any) -> MobileValidateError:
337
+ cls = ERROR_CLASSES.get(code, APIError)
338
+ return cls(message, code=code, **kw)
339
+
340
+
341
+ def error_from_response(status: int, body: Any, headers: Mapping[str, str]) -> MobileValidateError:
342
+ raw = body.get("error") if isinstance(body, dict) else None
343
+ e: Dict[str, Any] = raw if isinstance(raw, dict) else {}
344
+
345
+ def s(k: str) -> Optional[str]:
346
+ v = e.get(k)
347
+ return v if isinstance(v, str) else None
348
+
349
+ code = s("code") or _code_for_status(status)
350
+ retryable = e.get("retryable")
351
+ if not isinstance(retryable, bool):
352
+ retryable = _RETRYABLE.get(code, status == 408 or status >= 500)
353
+ return make_error(
354
+ code,
355
+ s("message") or f"Request failed with status {status}",
356
+ status=status,
357
+ retryable=retryable,
358
+ request_id=s("request_id") or headers.get("x-request-id"),
359
+ param=s("param"),
360
+ doc_url=s("doc_url"),
361
+ suggestion=s("suggestion"),
362
+ retry_after=parse_retry_after(headers.get("retry-after")),
363
+ body=body,
364
+ )
@@ -0,0 +1,285 @@
1
+ """HTTP transport (sync + async) with retries, jittered backoff, Retry-After and automatic idempotency keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import json
8
+ import math
9
+ import random as _random
10
+ import time
11
+ import uuid
12
+ from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Iterator, Mapping, Optional, Tuple
13
+
14
+ import httpx
15
+
16
+ from ._constants import __version__
17
+ from ._errors import (
18
+ APIConnectionError,
19
+ APITimeoutError,
20
+ InvalidResponseError,
21
+ MissingApiKeyError,
22
+ MobileValidateError,
23
+ error_from_response,
24
+ )
25
+ from .types import APIObject
26
+
27
+ MAX_BACKOFF_S = 8.0
28
+ #: Server hints longer than this are not worth blocking a caller for (e.g. daily caps): the error is raised instead.
29
+ MAX_RETRY_AFTER_S = 60.0
30
+
31
+ Query = Optional[Mapping[str, Any]]
32
+
33
+
34
+ def backoff_seconds(attempt: int, retry_after: Optional[float], rnd: Callable[[], float]) -> float:
35
+ """Exponential backoff with full jitter; Retry-After wins when present."""
36
+ if retry_after is not None:
37
+ return retry_after
38
+ return float(rnd()) * min(MAX_BACKOFF_S, math.ldexp(0.5, attempt))
39
+
40
+
41
+ def _qs_value(v: Any) -> str:
42
+ if isinstance(v, bool):
43
+ return "true" if v else "false"
44
+ return str(v)
45
+
46
+
47
+ class _Base:
48
+ def __init__(
49
+ self,
50
+ *,
51
+ api_key: Optional[str],
52
+ base_url: str,
53
+ timeout: float,
54
+ max_retries: int,
55
+ rnd: Callable[[], float] = _random.random,
56
+ ) -> None:
57
+ self.api_key = api_key
58
+ self.base_url = base_url.rstrip("/")
59
+ self.timeout = timeout
60
+ self.max_retries = max_retries
61
+ self.random = rnd
62
+
63
+ def _prepare(
64
+ self, method: str, path: str, query: Query, body: Any, idempotency_key: Optional[str],
65
+ accept: str = "application/json",
66
+ ) -> Tuple[str, Dict[str, str], Optional[Dict[str, str]], Optional[bytes]]:
67
+ if not self.api_key:
68
+ raise MissingApiKeyError(
69
+ "No API key. Pass api_key=..., set MOBILEVALIDATE_API_KEY, or use sandbox=True for the public sandbox key."
70
+ )
71
+ headers = {
72
+ "Authorization": f"Bearer {self.api_key}",
73
+ "Accept": accept,
74
+ "User-Agent": f"mobilevalidate-python/{__version__}",
75
+ }
76
+ content = None
77
+ if body is not None:
78
+ headers["Content-Type"] = "application/json"
79
+ content = json.dumps(body, separators=(",", ":")).encode("utf-8")
80
+ if idempotency_key:
81
+ headers["Idempotency-Key"] = idempotency_key
82
+ params = {k: _qs_value(v) for k, v in (query or {}).items() if v is not None}
83
+ return self.base_url + path, headers, params or None, content
84
+
85
+ @staticmethod
86
+ def _idempotency(method: str, key: Optional[str]) -> Optional[str]:
87
+ # One key per logical call, reused across retries so the server can deduplicate.
88
+ return (key or str(uuid.uuid4())) if method == "POST" else None
89
+
90
+ @staticmethod
91
+ def _parse(response: httpx.Response) -> APIObject:
92
+ headers = response.headers
93
+ text = response.text
94
+ body: Any = None
95
+ parse_failed = False
96
+ if text:
97
+ try:
98
+ body = json.loads(text)
99
+ except ValueError:
100
+ parse_failed = True
101
+ if response.status_code >= 400:
102
+ raise error_from_response(response.status_code, body, headers)
103
+ request_id = headers.get("x-request-id")
104
+ if parse_failed:
105
+ raise InvalidResponseError("Response was not valid JSON", status=response.status_code, request_id=request_id)
106
+ if isinstance(body, dict):
107
+ rid = request_id or (body.get("request_id") if isinstance(body.get("request_id"), str) else None)
108
+ return APIObject(body, rid)
109
+ obj = APIObject({}, request_id)
110
+ if body is not None:
111
+ obj["data"] = body
112
+ return obj
113
+
114
+ @staticmethod
115
+ def _wrap_transport_error(e: Exception, timeout: float) -> MobileValidateError:
116
+ if isinstance(e, httpx.TimeoutException):
117
+ return APITimeoutError(f"Request timed out after {timeout:g} s")
118
+ return APIConnectionError(f"Network error: {e}")
119
+
120
+ def _should_retry(self, err: MobileValidateError, attempt: int, max_retries: int) -> bool:
121
+ if not err.retryable or attempt >= max_retries:
122
+ return False
123
+ return not (err.retry_after is not None and err.retry_after > MAX_RETRY_AFTER_S)
124
+
125
+
126
+ class SyncTransport(_Base):
127
+ def __init__(
128
+ self,
129
+ *,
130
+ http_client: Optional[httpx.Client] = None,
131
+ sleep: Callable[[float], None] = time.sleep,
132
+ **kw: Any,
133
+ ) -> None:
134
+ super().__init__(**kw)
135
+ self._owns_client = http_client is None
136
+ self.client = http_client or httpx.Client()
137
+ self.sleep = sleep
138
+
139
+ def close(self) -> None:
140
+ if self._owns_client:
141
+ self.client.close()
142
+
143
+ def request(
144
+ self,
145
+ method: str,
146
+ path: str,
147
+ *,
148
+ query: Query = None,
149
+ body: Any = None,
150
+ idempotency_key: Optional[str] = None,
151
+ timeout: Optional[float] = None,
152
+ extra_timeout: float = 0,
153
+ max_retries: Optional[int] = None,
154
+ ) -> APIObject:
155
+ key = self._idempotency(method, idempotency_key)
156
+ url, headers, params, content = self._prepare(method, path, query, body, key)
157
+ total_timeout = (self.timeout if timeout is None else timeout) + extra_timeout
158
+ retries = self.max_retries if max_retries is None else max_retries
159
+ attempt = 0
160
+ while True:
161
+ try:
162
+ try:
163
+ response = self.client.request(
164
+ method, url, headers=headers, params=params, content=content, timeout=total_timeout
165
+ )
166
+ except httpx.TransportError as e:
167
+ raise self._wrap_transport_error(e, total_timeout) from e
168
+ return self._parse(response)
169
+ except MobileValidateError as err:
170
+ if not self._should_retry(err, attempt, retries):
171
+ raise
172
+ self.sleep(backoff_seconds(attempt, err.retry_after, self.random))
173
+ attempt += 1
174
+
175
+
176
+ @contextlib.contextmanager
177
+ def stream(
178
+ self, path: str, *, query: Query = None, accept: str, timeout: Optional[float] = None,
179
+ max_retries: Optional[int] = None,
180
+ ) -> Iterator[httpx.Response]:
181
+ """GET a streamed (non-JSON) body. Error responses are parsed and raised like :meth:`request`; the timeout
182
+ covers each read, not the whole transfer."""
183
+ url, headers, params, _ = self._prepare("GET", path, query, None, None, accept)
184
+ total_timeout = self.timeout if timeout is None else timeout
185
+ retries = self.max_retries if max_retries is None else max_retries
186
+ attempt = 0
187
+ while True:
188
+ try:
189
+ with self.client.stream("GET", url, headers=headers, params=params, timeout=total_timeout) as response:
190
+ if response.status_code >= 400:
191
+ response.read()
192
+ self._parse(response)
193
+ yield response
194
+ return
195
+ except httpx.TransportError as e:
196
+ err: MobileValidateError = self._wrap_transport_error(e, total_timeout)
197
+ if not self._should_retry(err, attempt, retries):
198
+ raise err from e
199
+ except MobileValidateError as e:
200
+ if not self._should_retry(e, attempt, retries):
201
+ raise
202
+ err = e
203
+ self.sleep(backoff_seconds(attempt, err.retry_after, self.random))
204
+ attempt += 1
205
+
206
+
207
+ class AsyncTransport(_Base):
208
+ def __init__(
209
+ self,
210
+ *,
211
+ http_client: Optional[httpx.AsyncClient] = None,
212
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
213
+ **kw: Any,
214
+ ) -> None:
215
+ super().__init__(**kw)
216
+ self._owns_client = http_client is None
217
+ self.client = http_client or httpx.AsyncClient()
218
+ self.sleep = sleep
219
+
220
+ async def close(self) -> None:
221
+ if self._owns_client:
222
+ await self.client.aclose()
223
+
224
+ async def request(
225
+ self,
226
+ method: str,
227
+ path: str,
228
+ *,
229
+ query: Query = None,
230
+ body: Any = None,
231
+ idempotency_key: Optional[str] = None,
232
+ timeout: Optional[float] = None,
233
+ extra_timeout: float = 0,
234
+ max_retries: Optional[int] = None,
235
+ ) -> APIObject:
236
+ key = self._idempotency(method, idempotency_key)
237
+ url, headers, params, content = self._prepare(method, path, query, body, key)
238
+ total_timeout = (self.timeout if timeout is None else timeout) + extra_timeout
239
+ retries = self.max_retries if max_retries is None else max_retries
240
+ attempt = 0
241
+ while True:
242
+ try:
243
+ try:
244
+ response = await self.client.request(
245
+ method, url, headers=headers, params=params, content=content, timeout=total_timeout
246
+ )
247
+ except httpx.TransportError as e:
248
+ raise self._wrap_transport_error(e, total_timeout) from e
249
+ return self._parse(response)
250
+ except MobileValidateError as err:
251
+ if not self._should_retry(err, attempt, retries):
252
+ raise
253
+ await self.sleep(backoff_seconds(attempt, err.retry_after, self.random))
254
+ attempt += 1
255
+
256
+
257
+ @contextlib.asynccontextmanager
258
+ async def stream(
259
+ self, path: str, *, query: Query = None, accept: str, timeout: Optional[float] = None,
260
+ max_retries: Optional[int] = None,
261
+ ) -> AsyncIterator[httpx.Response]:
262
+ """GET a streamed (non-JSON) body; see :meth:`SyncTransport.stream`."""
263
+ url, headers, params, _ = self._prepare("GET", path, query, None, None, accept)
264
+ total_timeout = self.timeout if timeout is None else timeout
265
+ retries = self.max_retries if max_retries is None else max_retries
266
+ attempt = 0
267
+ while True:
268
+ try:
269
+ async with self.client.stream("GET", url, headers=headers, params=params,
270
+ timeout=total_timeout) as response:
271
+ if response.status_code >= 400:
272
+ await response.aread()
273
+ self._parse(response)
274
+ yield response
275
+ return
276
+ except httpx.TransportError as e:
277
+ err: MobileValidateError = self._wrap_transport_error(e, total_timeout)
278
+ if not self._should_retry(err, attempt, retries):
279
+ raise err from e
280
+ except MobileValidateError as e:
281
+ if not self._should_retry(e, attempt, retries):
282
+ raise
283
+ err = e
284
+ await self.sleep(backoff_seconds(attempt, err.retry_after, self.random))
285
+ attempt += 1
File without changes