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.
- mobilevalidate/__init__.py +65 -0
- mobilevalidate/_client.py +612 -0
- mobilevalidate/_constants.py +42 -0
- mobilevalidate/_errors.py +364 -0
- mobilevalidate/_http.py +285 -0
- mobilevalidate/py.typed +0 -0
- mobilevalidate/types.py +166 -0
- mobilevalidate/webhooks.py +103 -0
- mobilevalidate_sdk-1.0.0.dist-info/METADATA +339 -0
- mobilevalidate_sdk-1.0.0.dist-info/RECORD +12 -0
- mobilevalidate_sdk-1.0.0.dist-info/WHEEL +4 -0
- mobilevalidate_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
)
|
mobilevalidate/_http.py
ADDED
|
@@ -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
|
mobilevalidate/py.typed
ADDED
|
File without changes
|