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