blu-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.
- blu/__init__.py +27 -0
- blu/_async.py +44 -0
- blu/_core.py +428 -0
- blu/_errors.py +93 -0
- blu/_generated.py +3080 -0
- blu/_uploads.py +199 -0
- blu/_webhooks.py +78 -0
- blu_sdk-1.0.0.dist-info/METADATA +178 -0
- blu_sdk-1.0.0.dist-info/RECORD +12 -0
- blu_sdk-1.0.0.dist-info/WHEEL +5 -0
- blu_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
- blu_sdk-1.0.0.dist-info/top_level.txt +1 -0
blu/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""BLU API client. No dependencies. See README.md."""
|
|
2
|
+
from ._async import AsyncBlu
|
|
3
|
+
from ._core import Blu, BluDownload, BluResponse
|
|
4
|
+
from ._errors import (
|
|
5
|
+
BluAllowanceError,
|
|
6
|
+
BluAuthError,
|
|
7
|
+
BluBadRequestError,
|
|
8
|
+
BluConflictError,
|
|
9
|
+
BluConnectionError,
|
|
10
|
+
BluError,
|
|
11
|
+
BluNotFoundError,
|
|
12
|
+
BluPaginationError,
|
|
13
|
+
BluPermissionError,
|
|
14
|
+
BluRateLimitError,
|
|
15
|
+
BluServerError,
|
|
16
|
+
BluUploadError,
|
|
17
|
+
)
|
|
18
|
+
from ._generated import SDK_VERSION, SPEC_VERSION
|
|
19
|
+
from ._webhooks import SignatureCheck, check_signature, parse_signature_header, verify_signature
|
|
20
|
+
|
|
21
|
+
__version__ = SDK_VERSION
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AsyncBlu", "Blu", "BluDownload", "BluResponse",
|
|
24
|
+
"BluAllowanceError", "BluAuthError", "BluBadRequestError", "BluConflictError", "BluConnectionError", "BluError",
|
|
25
|
+
"BluNotFoundError", "BluPaginationError", "BluPermissionError", "BluRateLimitError", "BluServerError", "BluUploadError",
|
|
26
|
+
"SDK_VERSION", "SPEC_VERSION", "SignatureCheck", "check_signature", "parse_signature_header", "verify_signature",
|
|
27
|
+
]
|
blu/_async.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The same client for asyncio. It runs the standard-library client on a worker thread, so a call
|
|
2
|
+
never blocks your event loop. Methods ending in _all are async iterators."""
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from ._core import Blu
|
|
6
|
+
from ._generated import GROUPS
|
|
7
|
+
|
|
8
|
+
_END = object()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def _walk(method, args, kwargs):
|
|
12
|
+
iterator = await asyncio.to_thread(method, *args, **kwargs)
|
|
13
|
+
while True:
|
|
14
|
+
item = await asyncio.to_thread(next, iterator, _END)
|
|
15
|
+
if item is _END:
|
|
16
|
+
return
|
|
17
|
+
yield item
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _AsyncGroup:
|
|
21
|
+
def __init__(self, group):
|
|
22
|
+
self._group = group
|
|
23
|
+
|
|
24
|
+
def __getattr__(self, name):
|
|
25
|
+
method = getattr(self._group, name)
|
|
26
|
+
if name.endswith("_all"):
|
|
27
|
+
return lambda *args, **kwargs: _walk(method, args, kwargs)
|
|
28
|
+
|
|
29
|
+
async def call(*args, **kwargs):
|
|
30
|
+
return await asyncio.to_thread(method, *args, **kwargs)
|
|
31
|
+
|
|
32
|
+
return call
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AsyncBlu:
|
|
36
|
+
"""Takes what Blu takes. await client.chats.send(...), async for row in client.chats.list_all(...)."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, api_key=None, **config):
|
|
39
|
+
self._sync = Blu(api_key, **config)
|
|
40
|
+
for name in GROUPS:
|
|
41
|
+
setattr(self, name, _AsyncGroup(getattr(self._sync, name)))
|
|
42
|
+
|
|
43
|
+
def __repr__(self):
|
|
44
|
+
return "Async%r" % (self._sync,)
|
blu/_core.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""The hand-written core. Everything easy to get wrong lives here, once.
|
|
2
|
+
|
|
3
|
+
- One Idempotency-Key per call, kept across that call's own retries, so a retry can't send twice.
|
|
4
|
+
A new call gets a new key, and that is how you try a ``not_sent`` again on purpose.
|
|
5
|
+
- Retries only when BLU or the network says trying later is safe. A 2xx is never retried, and that
|
|
6
|
+
includes 202 ``unknown``. An error that carries an ``outcome`` is never retried. A write the API
|
|
7
|
+
doesn't hold to its key goes again only when BLU itself said to.
|
|
8
|
+
- http.client directly, so a redirect is never followed and the key never reaches another host.
|
|
9
|
+
"""
|
|
10
|
+
import datetime
|
|
11
|
+
import email.utils
|
|
12
|
+
import http.client
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
import random
|
|
17
|
+
import re
|
|
18
|
+
import ssl
|
|
19
|
+
import time
|
|
20
|
+
import urllib.parse
|
|
21
|
+
import uuid
|
|
22
|
+
|
|
23
|
+
from ._errors import BluConnectionError, BluError, BluPaginationError, error_class_for
|
|
24
|
+
from ._generated import BASE_URL, GROUPS, OPERATIONS, SDK_VERSION
|
|
25
|
+
from ._uploads import upload_to_vault
|
|
26
|
+
|
|
27
|
+
# What an edge says when the answer never got through: the same as a dropped connection.
|
|
28
|
+
_TRANSIENT_STATUS = frozenset([429, 502, 503, 504, 520, 521, 522, 523, 524, 530])
|
|
29
|
+
_KEY_FORMAT = re.compile(r"[\x21-\x7e]{1,255}")
|
|
30
|
+
_API_KEY_FORMAT = re.compile(r"[\x21-\x7e]+")
|
|
31
|
+
_LOOPBACK = frozenset(["127.0.0.1", "localhost", "::1"])
|
|
32
|
+
_JOB_IN_URL = re.compile(r"/v1/(?:creators/[^/]+/mass-messages/)?jobs/([^/?#]+)")
|
|
33
|
+
# BLU settles a request within a minute. Until then "still running" is worth waiting for: a caller
|
|
34
|
+
# who took it for a failure and sent again with a new key would send twice.
|
|
35
|
+
_IN_FLIGHT_WAIT_SECONDS = 75.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Hidden:
|
|
39
|
+
"""Holds the key so that it can't reach a log through repr, str or vars."""
|
|
40
|
+
|
|
41
|
+
__slots__ = ("_value",)
|
|
42
|
+
|
|
43
|
+
def __init__(self, value):
|
|
44
|
+
self._value = value
|
|
45
|
+
|
|
46
|
+
def __repr__(self):
|
|
47
|
+
return "<hidden>"
|
|
48
|
+
|
|
49
|
+
__str__ = __repr__
|
|
50
|
+
|
|
51
|
+
def reveal(self):
|
|
52
|
+
return self._value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_retry_after(value):
|
|
56
|
+
if value is None:
|
|
57
|
+
return None
|
|
58
|
+
text = str(value).strip()
|
|
59
|
+
if re.fullmatch(r"[0-9]+", text):
|
|
60
|
+
return int(text) * 1000
|
|
61
|
+
try:
|
|
62
|
+
at = email.utils.parsedate_to_datetime(text)
|
|
63
|
+
except (TypeError, ValueError):
|
|
64
|
+
return None
|
|
65
|
+
if at is None:
|
|
66
|
+
return None
|
|
67
|
+
if at.tzinfo is None:
|
|
68
|
+
at = at.replace(tzinfo=datetime.timezone.utc)
|
|
69
|
+
return max(0, int((at - datetime.datetime.now(datetime.timezone.utc)).total_seconds() * 1000))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _path_argument(name, value, op_id):
|
|
73
|
+
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
|
74
|
+
raise TypeError("%s: %s must be a string or a number, got %s." % (op_id, name, type(value).__name__))
|
|
75
|
+
text = str(value)
|
|
76
|
+
if not text or text in (".", ".."):
|
|
77
|
+
raise TypeError("%s: %s isn't a usable id." % (op_id, name))
|
|
78
|
+
return urllib.parse.quote(text, safe="")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _query_string(query):
|
|
82
|
+
pairs = []
|
|
83
|
+
for name, value in (query or {}).items():
|
|
84
|
+
if value is None:
|
|
85
|
+
continue
|
|
86
|
+
for one in value if isinstance(value, (list, tuple)) else [value]:
|
|
87
|
+
if isinstance(one, bool):
|
|
88
|
+
one = "true" if one else "false"
|
|
89
|
+
elif isinstance(one, (datetime.datetime, datetime.date)):
|
|
90
|
+
one = one.isoformat()
|
|
91
|
+
pairs.append((name, str(one)))
|
|
92
|
+
return "?" + urllib.parse.urlencode(pairs, quote_via=urllib.parse.quote) if pairs else ""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class BluResponse:
|
|
96
|
+
"""The answer to a call. outcome, action_id, status_url and job_id are there on a write."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, status, body, headers, idempotency_key):
|
|
99
|
+
body = body if isinstance(body, dict) else {}
|
|
100
|
+
data = body.get("data")
|
|
101
|
+
self.status = status
|
|
102
|
+
self.data = data
|
|
103
|
+
self.outcome = body.get("outcome")
|
|
104
|
+
self.action_id = body.get("actionId") or headers.get("x-blu-action-id")
|
|
105
|
+
data_dict = data if isinstance(data, dict) else {}
|
|
106
|
+
self.status_url = body.get("statusUrl") or data_dict.get("statusUrl")
|
|
107
|
+
found = _JOB_IN_URL.search(str(self.status_url or ""))
|
|
108
|
+
self.job_id = data_dict.get("jobId") or (urllib.parse.unquote(found.group(1)) if found else None)
|
|
109
|
+
self.error_message = body.get("errorMessage")
|
|
110
|
+
self.replayed = headers.get("idempotent-replayed") == "true"
|
|
111
|
+
self.idempotency_key = idempotency_key
|
|
112
|
+
self.request_id = headers.get("x-request-id")
|
|
113
|
+
credits_left = headers.get("x-blu-credits-remaining")
|
|
114
|
+
self.credits_remaining = int(credits_left) if credits_left is not None and credits_left.isdigit() else None
|
|
115
|
+
self.headers = headers
|
|
116
|
+
if isinstance(body.get("hasMore"), bool):
|
|
117
|
+
self.items = data_dict.get("list") if isinstance(data_dict.get("list"), list) else []
|
|
118
|
+
self.cursor = body.get("cursor") if isinstance(body.get("cursor"), str) else ""
|
|
119
|
+
self.has_more = body["hasMore"]
|
|
120
|
+
|
|
121
|
+
def __repr__(self):
|
|
122
|
+
return "BluResponse(status=%r, outcome=%r, action_id=%r)" % (self.status, self.outcome, self.action_id)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class BluDownload:
|
|
126
|
+
"""A file coming back from the API. read(n), or save_to(path). Close it, or use ``with``."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, status, headers, response, connection):
|
|
129
|
+
self.status = status
|
|
130
|
+
self.headers = headers
|
|
131
|
+
self._response = response
|
|
132
|
+
self._connection = connection
|
|
133
|
+
length = headers.get("content-length")
|
|
134
|
+
self.content_length = int(length) if length and length.isdigit() else None
|
|
135
|
+
self.content_type = headers.get("content-type")
|
|
136
|
+
self.request_id = headers.get("x-request-id")
|
|
137
|
+
|
|
138
|
+
def read(self, size=-1):
|
|
139
|
+
return self._response.read(size)
|
|
140
|
+
|
|
141
|
+
def save_to(self, path, chunk_size=1024 * 1024):
|
|
142
|
+
try:
|
|
143
|
+
with open(path, "wb") as out:
|
|
144
|
+
while True:
|
|
145
|
+
chunk = self._response.read(chunk_size)
|
|
146
|
+
if not chunk:
|
|
147
|
+
return path
|
|
148
|
+
out.write(chunk)
|
|
149
|
+
finally:
|
|
150
|
+
self.close()
|
|
151
|
+
|
|
152
|
+
def close(self):
|
|
153
|
+
self._response.close()
|
|
154
|
+
self._connection.close()
|
|
155
|
+
|
|
156
|
+
def __enter__(self):
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def __exit__(self, *_exc):
|
|
160
|
+
self.close()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Blu:
|
|
164
|
+
"""The client. Every endpoint is a method on a group, such as client.chats.send(...)."""
|
|
165
|
+
|
|
166
|
+
def __init__(self, api_key=None, *, base_url=BASE_URL, timeout=70.0, max_retries=3, max_retry_delay=60.0, retry_base_delay=0.5):
|
|
167
|
+
key = os.environ.get("BLU_KEY") if api_key is None else api_key
|
|
168
|
+
if not isinstance(key, str) or not key:
|
|
169
|
+
raise TypeError("No API key. Pass api_key, or set BLU_KEY.")
|
|
170
|
+
if not _API_KEY_FORMAT.fullmatch(key):
|
|
171
|
+
raise TypeError("That API key has spaces or control characters in it, so it can't be right.")
|
|
172
|
+
self._authorization = _Hidden("Bearer " + key)
|
|
173
|
+
parts = urllib.parse.urlsplit(str(base_url))
|
|
174
|
+
secure = parts.scheme == "https"
|
|
175
|
+
if not parts.hostname or (not secure and not (parts.scheme == "http" and parts.hostname in _LOOPBACK)):
|
|
176
|
+
raise TypeError("base_url must use https. Plain http is allowed only on this machine, for tests.")
|
|
177
|
+
if parts.username or parts.password:
|
|
178
|
+
raise TypeError("base_url can't hold a username or password.")
|
|
179
|
+
if parts.path not in ("", "/") or parts.query or parts.fragment:
|
|
180
|
+
raise TypeError("base_url is only the address, such as https://api.blu.luxe.")
|
|
181
|
+
self._secure = secure
|
|
182
|
+
self._host = parts.hostname
|
|
183
|
+
self._port = parts.port
|
|
184
|
+
self.base_url = "%s://%s" % (parts.scheme, parts.netloc)
|
|
185
|
+
self.timeout = timeout
|
|
186
|
+
self.max_retries = max_retries
|
|
187
|
+
self.max_retry_delay = max_retry_delay
|
|
188
|
+
self.retry_base_delay = retry_base_delay
|
|
189
|
+
self._user_agent = "blu-sdk-python/%s python/%s" % (SDK_VERSION, platform.python_version())
|
|
190
|
+
for name, group in GROUPS.items():
|
|
191
|
+
setattr(self, name, group(self))
|
|
192
|
+
self.vault.upload = lambda creator_id, **kwargs: upload_to_vault(self, creator_id, **kwargs)
|
|
193
|
+
|
|
194
|
+
def __repr__(self):
|
|
195
|
+
return "Blu(base_url=%r)" % (self.base_url,)
|
|
196
|
+
|
|
197
|
+
def _call(self, op_id, path_args, payload=None, *, idempotency_key=None, dry_run=False, timeout=None,
|
|
198
|
+
max_retries=None, content_length=None, range=None): # pylint: disable=redefined-builtin
|
|
199
|
+
"""One call: every generated method lands here."""
|
|
200
|
+
op = OPERATIONS[op_id]
|
|
201
|
+
target = self._target(op, path_args, payload, dry_run)
|
|
202
|
+
headers = {
|
|
203
|
+
"Authorization": self._authorization.reveal(),
|
|
204
|
+
"Accept": "*/*" if op.get("binary") else "application/json",
|
|
205
|
+
"User-Agent": self._user_agent,
|
|
206
|
+
}
|
|
207
|
+
key = self._key_for(idempotency_key) if op["write"] else None
|
|
208
|
+
if key:
|
|
209
|
+
headers["Idempotency-Key"] = key
|
|
210
|
+
if range:
|
|
211
|
+
headers["Range"] = str(range)
|
|
212
|
+
make_body, replayable, owns_body = self._prepare_body(op, payload, content_length, headers)
|
|
213
|
+
retries = self.max_retries if max_retries is None else max_retries
|
|
214
|
+
wait_for = self.timeout if timeout is None else timeout
|
|
215
|
+
attempt = 0
|
|
216
|
+
waited_since = None
|
|
217
|
+
while True:
|
|
218
|
+
try:
|
|
219
|
+
answer = self._send(op["method"], target, headers, make_body, owns_body, wait_for, op.get("binary", False))
|
|
220
|
+
except (OSError, http.client.HTTPException) as err:
|
|
221
|
+
delay = None
|
|
222
|
+
if replayable and op.get("retry_safe") and not isinstance(err, ssl.SSLCertVerificationError):
|
|
223
|
+
delay = self._retry_delay(None, None, True, attempt, retries)
|
|
224
|
+
if delay is None:
|
|
225
|
+
raise self._connection_error(err, op, key) from None
|
|
226
|
+
time.sleep(delay)
|
|
227
|
+
attempt += 1
|
|
228
|
+
continue
|
|
229
|
+
status = answer["status"]
|
|
230
|
+
if 200 <= status < 300:
|
|
231
|
+
return self._success(op, answer, key)
|
|
232
|
+
if 300 <= status < 400:
|
|
233
|
+
raise BluError("BLU answered HTTP %d, a redirect. It isn't followed, because your key would go with it." % status,
|
|
234
|
+
status=status, code="unexpected_redirect", idempotency_key=key)
|
|
235
|
+
error, declared = self._error_from(answer, key)
|
|
236
|
+
if error.code == "idempotency_in_flight" and retries > 0 and replayable:
|
|
237
|
+
waited_since = time.monotonic() if waited_since is None else waited_since
|
|
238
|
+
if time.monotonic() - waited_since < _IN_FLIGHT_WAIT_SECONDS:
|
|
239
|
+
time.sleep((error.retry_after_ms or 2000) / 1000.0)
|
|
240
|
+
continue
|
|
241
|
+
# An edge's error says nothing about whether the call ran. Only BLU's own "try later" does.
|
|
242
|
+
delay = None
|
|
243
|
+
if replayable and (op.get("retry_safe") or declared is True):
|
|
244
|
+
delay = self._retry_delay(error, declared, False, attempt, retries)
|
|
245
|
+
if delay is None:
|
|
246
|
+
raise error
|
|
247
|
+
time.sleep(delay)
|
|
248
|
+
attempt += 1
|
|
249
|
+
|
|
250
|
+
def _iterate(self, op_id, path_args, query=None, **options):
|
|
251
|
+
"""Every row of a list, page after page. Only hasMore: false ends it."""
|
|
252
|
+
op = OPERATIONS[op_id]
|
|
253
|
+
asked = dict(query or {})
|
|
254
|
+
while True:
|
|
255
|
+
page = self._call(op_id, path_args, dict(asked), **options)
|
|
256
|
+
if not hasattr(page, "has_more"):
|
|
257
|
+
raise BluPaginationError("%s: the answer doesn't say whether there's more." % op_id, request_id=page.request_id)
|
|
258
|
+
for item in page.items:
|
|
259
|
+
yield item
|
|
260
|
+
if page.has_more is False:
|
|
261
|
+
return
|
|
262
|
+
if op.get("paging") == "since":
|
|
263
|
+
last = page.items[-1].get("id") if page.items else None
|
|
264
|
+
if not last or last == asked.get("since"):
|
|
265
|
+
raise BluPaginationError("%s: BLU says there's more, but no new event id came back to continue from." % op_id, request_id=page.request_id)
|
|
266
|
+
asked["since"] = last
|
|
267
|
+
else:
|
|
268
|
+
if not page.cursor or page.cursor == asked.get("cursor"):
|
|
269
|
+
raise BluPaginationError("%s: BLU says there's more, but gave no new cursor to get it. This list can't be walked past this page." % op_id, request_id=page.request_id)
|
|
270
|
+
asked["cursor"] = page.cursor
|
|
271
|
+
|
|
272
|
+
def _target(self, op, path_args, payload, dry_run):
|
|
273
|
+
args = iter(path_args)
|
|
274
|
+
|
|
275
|
+
def fill(match):
|
|
276
|
+
return _path_argument(match.group(1), next(args), op["id"])
|
|
277
|
+
|
|
278
|
+
filled = re.sub(r"\{([A-Za-z0-9_]+)\}", fill, op["path"])
|
|
279
|
+
if op["write"]:
|
|
280
|
+
query = {"dry_run": "true"} if dry_run else None
|
|
281
|
+
else:
|
|
282
|
+
query = payload
|
|
283
|
+
if query is not None and not isinstance(query, dict):
|
|
284
|
+
raise TypeError("%s: the query must be a dict." % op["id"])
|
|
285
|
+
return filled + _query_string(query)
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def _key_for(given):
|
|
289
|
+
if given is None:
|
|
290
|
+
return str(uuid.uuid4())
|
|
291
|
+
if not isinstance(given, str) or not _KEY_FORMAT.fullmatch(given):
|
|
292
|
+
raise TypeError("idempotency_key must be 1 to 255 printable characters, with no spaces.")
|
|
293
|
+
return given
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _prepare_body(op, payload, content_length, headers):
|
|
297
|
+
"""(make_body, replayable, owns_body). Bytes can go again, and so can a function that opens
|
|
298
|
+
the stream afresh. A stream handed over as it is can be read once, so it isn't retried."""
|
|
299
|
+
if op.get("raw_body"):
|
|
300
|
+
headers["Content-Type"] = "application/octet-stream"
|
|
301
|
+
if isinstance(payload, (bytes, bytearray, memoryview, str)):
|
|
302
|
+
data = payload.encode("utf-8") if isinstance(payload, str) else bytes(payload)
|
|
303
|
+
headers["Content-Length"] = str(len(data))
|
|
304
|
+
return (lambda: data), True, False
|
|
305
|
+
if content_length is None:
|
|
306
|
+
raise TypeError("%s: a stream needs content_length: BLU checks each part's exact size." % op["id"])
|
|
307
|
+
headers["Content-Length"] = str(content_length)
|
|
308
|
+
if callable(payload):
|
|
309
|
+
return payload, True, True
|
|
310
|
+
if hasattr(payload, "read"):
|
|
311
|
+
return (lambda: payload), False, False
|
|
312
|
+
raise TypeError("%s: the part must be bytes, a file object, or a function that returns one." % op["id"])
|
|
313
|
+
if not op["write"] or not op.get("has_body"):
|
|
314
|
+
if op["write"] and payload is not None:
|
|
315
|
+
raise TypeError("%s takes no body." % op["id"])
|
|
316
|
+
if op["write"]:
|
|
317
|
+
headers["Content-Length"] = "0"
|
|
318
|
+
return (lambda: None), True, False
|
|
319
|
+
if payload is None:
|
|
320
|
+
if op.get("body_required"):
|
|
321
|
+
raise TypeError("%s needs a body." % op["id"])
|
|
322
|
+
headers["Content-Length"] = "0"
|
|
323
|
+
return (lambda: None), True, False
|
|
324
|
+
if not isinstance(payload, dict):
|
|
325
|
+
raise TypeError("%s: the body must be a dict." % op["id"])
|
|
326
|
+
data = json.dumps(payload).encode("utf-8")
|
|
327
|
+
headers["Content-Type"] = "application/json"
|
|
328
|
+
headers["Content-Length"] = str(len(data))
|
|
329
|
+
return (lambda: data), True, False
|
|
330
|
+
|
|
331
|
+
def _retry_delay(self, error, declared, connection, attempt, retries):
|
|
332
|
+
"""Seconds to wait before trying again, or None when this answer must not be retried."""
|
|
333
|
+
if attempt >= retries:
|
|
334
|
+
return None
|
|
335
|
+
if not connection:
|
|
336
|
+
# What BLU said about a write is final. A not_sent needs a new call and an unknown needs a person.
|
|
337
|
+
if error.outcome in ("unknown", "not_sent"):
|
|
338
|
+
return None
|
|
339
|
+
if declared is False:
|
|
340
|
+
return None
|
|
341
|
+
if declared is not True and error.status not in _TRANSIENT_STATUS:
|
|
342
|
+
return None
|
|
343
|
+
retry_after_ms = error.retry_after_ms if error is not None else None
|
|
344
|
+
if retry_after_ms is not None:
|
|
345
|
+
# Longer than we may wait: never earlier than BLU asked, so this one is the caller's to plan.
|
|
346
|
+
if retry_after_ms / 1000.0 > self.max_retry_delay:
|
|
347
|
+
return None
|
|
348
|
+
wait = retry_after_ms / 1000.0
|
|
349
|
+
return wait + random.random() * min(1.0, wait * 0.1)
|
|
350
|
+
ceiling = min(self.max_retry_delay, self.retry_base_delay * (2 ** attempt))
|
|
351
|
+
return ceiling / 2 + random.random() * (ceiling / 2)
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def _success(op, answer, key):
|
|
355
|
+
if op.get("binary"):
|
|
356
|
+
return BluDownload(answer["status"], answer["headers"], answer["response"], answer["connection"])
|
|
357
|
+
text = answer["text"]
|
|
358
|
+
body = None
|
|
359
|
+
if text:
|
|
360
|
+
try:
|
|
361
|
+
body = json.loads(text.decode("utf-8"))
|
|
362
|
+
except ValueError:
|
|
363
|
+
raise BluError("BLU answered HTTP %d, but not with JSON." % answer["status"], status=answer["status"], code="bad_response",
|
|
364
|
+
outcome="unknown" if op["write"] else None, idempotency_key=key,
|
|
365
|
+
request_id=answer["headers"].get("x-request-id")) from None
|
|
366
|
+
return BluResponse(answer["status"], body, answer["headers"], key)
|
|
367
|
+
|
|
368
|
+
@staticmethod
|
|
369
|
+
def _error_from(answer, key):
|
|
370
|
+
status, headers = answer["status"], answer["headers"]
|
|
371
|
+
try:
|
|
372
|
+
body = json.loads(answer["text"].decode("utf-8"))
|
|
373
|
+
except ValueError:
|
|
374
|
+
body = None
|
|
375
|
+
body = body if isinstance(body, dict) else {}
|
|
376
|
+
detail = body.get("error") if isinstance(body.get("error"), dict) else None
|
|
377
|
+
details = detail.get("details") if detail else None
|
|
378
|
+
details_dict = details if isinstance(details, dict) else {}
|
|
379
|
+
message = body.get("errorMessage") if isinstance(body.get("errorMessage"), str) and body.get("errorMessage") else "BLU answered HTTP %d." % status
|
|
380
|
+
retry_after = detail.get("retryAfterMs") if detail else None
|
|
381
|
+
if not isinstance(retry_after, (int, float)) or isinstance(retry_after, bool):
|
|
382
|
+
retry_after = _parse_retry_after(headers.get("retry-after"))
|
|
383
|
+
declared = detail.get("retryable") if detail and isinstance(detail.get("retryable"), bool) else None
|
|
384
|
+
error = error_class_for(status)(
|
|
385
|
+
message,
|
|
386
|
+
status=status,
|
|
387
|
+
code=detail.get("code") if detail else None,
|
|
388
|
+
outcome=detail.get("outcome") if detail else None,
|
|
389
|
+
retryable=declared if declared is not None else status in _TRANSIENT_STATUS,
|
|
390
|
+
retry_after_ms=retry_after,
|
|
391
|
+
details=details,
|
|
392
|
+
action_id=details_dict.get("actionId") or headers.get("x-blu-action-id"),
|
|
393
|
+
request_id=headers.get("x-request-id"),
|
|
394
|
+
idempotency_key=key,
|
|
395
|
+
replayed=headers.get("idempotent-replayed") == "true",
|
|
396
|
+
)
|
|
397
|
+
return error, declared
|
|
398
|
+
|
|
399
|
+
@staticmethod
|
|
400
|
+
def _connection_error(err, op, key):
|
|
401
|
+
write = op["write"]
|
|
402
|
+
advice = "."
|
|
403
|
+
if write:
|
|
404
|
+
advice = ", so it may or may not have happened. "
|
|
405
|
+
advice += "To ask again, pass the same idempotency_key: it can't send twice." if op.get("key_honored") else "Check before you try again."
|
|
406
|
+
return BluConnectionError("No answer from BLU" + advice, code=type(err).__name__, outcome="unknown" if write else None, idempotency_key=key)
|
|
407
|
+
|
|
408
|
+
def _send(self, method, target, headers, make_body, owns_body, timeout, want_stream):
|
|
409
|
+
"""One attempt. Returns the answer, raises when no answer arrived."""
|
|
410
|
+
if self._secure:
|
|
411
|
+
connection = http.client.HTTPSConnection(self._host, self._port, timeout=timeout, context=ssl.create_default_context())
|
|
412
|
+
else:
|
|
413
|
+
connection = http.client.HTTPConnection(self._host, self._port, timeout=timeout)
|
|
414
|
+
body = make_body()
|
|
415
|
+
keep_open = False
|
|
416
|
+
try:
|
|
417
|
+
connection.request(method, target, body=body, headers=headers)
|
|
418
|
+
response = connection.getresponse()
|
|
419
|
+
answer_headers = {name.lower(): value for name, value in response.getheaders()}
|
|
420
|
+
if want_stream and 200 <= response.status < 300:
|
|
421
|
+
keep_open = True
|
|
422
|
+
return {"status": response.status, "headers": answer_headers, "response": response, "connection": connection}
|
|
423
|
+
return {"status": response.status, "headers": answer_headers, "text": response.read()}
|
|
424
|
+
finally:
|
|
425
|
+
if owns_body and hasattr(body, "close"):
|
|
426
|
+
body.close()
|
|
427
|
+
if not keep_open:
|
|
428
|
+
connection.close()
|
blu/_errors.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Every failure carries what BLU said about it.
|
|
2
|
+
|
|
3
|
+
``outcome`` is the one that matters on a write:
|
|
4
|
+
"not_sent" nothing happened. To try again, make a new call (a new Idempotency-Key).
|
|
5
|
+
"unknown" OnlyFans may have done it. Check before you try again.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BluError(Exception):
|
|
10
|
+
def __init__(self, message, *, status=None, code=None, outcome=None, retryable=False, retry_after_ms=None,
|
|
11
|
+
details=None, action_id=None, request_id=None, idempotency_key=None, replayed=False):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.message = message
|
|
14
|
+
self.status = status
|
|
15
|
+
self.code = code
|
|
16
|
+
self.outcome = outcome
|
|
17
|
+
self.retryable = bool(retryable)
|
|
18
|
+
self.retry_after_ms = retry_after_ms
|
|
19
|
+
self.details = details
|
|
20
|
+
self.action_id = action_id
|
|
21
|
+
self.request_id = request_id
|
|
22
|
+
self.idempotency_key = idempotency_key
|
|
23
|
+
self.replayed = bool(replayed)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BluBadRequestError(BluError):
|
|
27
|
+
"""400 and 422: the request is wrong, and the message says what to fix."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BluAuthError(BluError):
|
|
31
|
+
"""401: the key is missing, wrong, expired or revoked."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BluAllowanceError(BluError):
|
|
35
|
+
"""402: this month's live calls are used up."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BluPermissionError(BluError):
|
|
39
|
+
"""403: the key can't do this."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BluNotFoundError(BluError):
|
|
43
|
+
"""404: no such thing for this key."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BluConflictError(BluError):
|
|
47
|
+
"""409: a conflict, such as Sophie handling the chat."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BluRateLimitError(BluError):
|
|
51
|
+
"""429: too many requests, a busy creator, or a daily limit."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BluServerError(BluError):
|
|
55
|
+
"""5xx."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BluConnectionError(BluError):
|
|
59
|
+
"""No usable answer arrived: a timeout or a dropped connection, after every retry.
|
|
60
|
+
|
|
61
|
+
On a write ``outcome`` is "unknown". To ask again, pass the same ``idempotency_key``: it
|
|
62
|
+
can't send twice.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class BluPaginationError(BluError):
|
|
67
|
+
"""BLU says there is more, but gave no way to get it. Stopping quietly would lose rows."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class BluUploadError(BluError):
|
|
71
|
+
"""The upload helper stopped. ``upload`` is the upload's last known state, when there is one."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, message, *, upload=None, **fields):
|
|
74
|
+
super().__init__(message, **fields)
|
|
75
|
+
self.upload = upload
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def error_class_for(status):
|
|
79
|
+
if status == 401:
|
|
80
|
+
return BluAuthError
|
|
81
|
+
if status == 402:
|
|
82
|
+
return BluAllowanceError
|
|
83
|
+
if status == 403:
|
|
84
|
+
return BluPermissionError
|
|
85
|
+
if status == 404:
|
|
86
|
+
return BluNotFoundError
|
|
87
|
+
if status == 409:
|
|
88
|
+
return BluConflictError
|
|
89
|
+
if status == 429:
|
|
90
|
+
return BluRateLimitError
|
|
91
|
+
if status >= 500:
|
|
92
|
+
return BluServerError
|
|
93
|
+
return BluBadRequestError
|