bfocus 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bfocus/__init__.py +45 -0
- bfocus/_client.py +95 -0
- bfocus/_resources.py +862 -0
- bfocus/_transport.py +335 -0
- bfocus/_version.py +3 -0
- bfocus/errors.py +131 -0
- bfocus/py.typed +0 -0
- bfocus/types.py +310 -0
- bfocus/widget.py +36 -0
- bfocus-0.1.0.dist-info/METADATA +384 -0
- bfocus-0.1.0.dist-info/RECORD +13 -0
- bfocus-0.1.0.dist-info/WHEEL +4 -0
- bfocus-0.1.0.dist-info/licenses/LICENSE +21 -0
bfocus/_transport.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Transporte HTTP: headers, codificação, novas tentativas e tradução de erros.
|
|
2
|
+
|
|
3
|
+
Só biblioteca padrão (``urllib``). Nada aqui faz rede na importação/construção.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import http.client
|
|
9
|
+
import json
|
|
10
|
+
import random
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import date, datetime, timezone
|
|
17
|
+
from email.utils import parsedate_to_datetime
|
|
18
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
from .errors import BfocusError, NetworkError, error_for_status
|
|
22
|
+
from .types import Unset
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.bfocus.com.br"
|
|
25
|
+
API_PREFIX = "/api/v1/integration"
|
|
26
|
+
#: Vai em ``X-Bfocus-Client`` e ``User-Agent``. É por ele que a API sabe qual versão da
|
|
27
|
+
#: SDK a conta roda — e avisa quando uma correção exigir atualizar.
|
|
28
|
+
CLIENT_ID = f"bfocus-python/{__version__}"
|
|
29
|
+
DEFAULT_TIMEOUT = 30.0
|
|
30
|
+
DEFAULT_MAX_RETRIES = 2
|
|
31
|
+
|
|
32
|
+
RETRY_STATUSES = frozenset({429, 502, 503, 504})
|
|
33
|
+
WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
34
|
+
MAX_RETRY_AFTER = 60.0
|
|
35
|
+
MAX_BACKOFF = 8.0
|
|
36
|
+
|
|
37
|
+
# Falhas de transporte que viram NetworkError (e são repetidas). ``socket.timeout`` e
|
|
38
|
+
# ``ConnectionError`` são OSError; ``RemoteDisconnected``/``IncompleteRead`` são HTTPException.
|
|
39
|
+
_NETWORK_FAILURES = (urllib.error.URLError, OSError, http.client.HTTPException)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── codificação ─────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def path_segment(value: Any, name: str, *, allow_slash: bool = True) -> str:
|
|
46
|
+
"""Percent-encode de UM segmento de caminho (``ERP 1042`` → ``ERP%201042``)."""
|
|
47
|
+
if isinstance(value, Unset) or value is None:
|
|
48
|
+
raise ValueError(f"{name} é obrigatório.")
|
|
49
|
+
text = str(value)
|
|
50
|
+
if text == "":
|
|
51
|
+
raise ValueError(f"{name} não pode ser vazio.")
|
|
52
|
+
if text in (".", ".."):
|
|
53
|
+
# O cliente HTTP resolveria "%2E%2E" como navegação de caminho e chamaria outra rota.
|
|
54
|
+
raise ValueError(f"{name} não pode ser {text!r}.")
|
|
55
|
+
if not allow_slash and "/" in text:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"{name} não aceita '/' (a API recusa) — use ':' para hierarquia: {text!r}"
|
|
58
|
+
)
|
|
59
|
+
return urllib.parse.quote(text, safe="")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def iso_utc(value: datetime) -> str:
|
|
63
|
+
"""``datetime`` → ISO 8601 em UTC com ``Z``. Sem fuso = já está em UTC."""
|
|
64
|
+
if value.tzinfo is None:
|
|
65
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
66
|
+
else:
|
|
67
|
+
value = value.astimezone(timezone.utc)
|
|
68
|
+
return value.replace(tzinfo=None).isoformat() + "Z"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def query_value(value: Any) -> str:
|
|
72
|
+
if isinstance(value, bool):
|
|
73
|
+
return "true" if value else "false"
|
|
74
|
+
if isinstance(value, datetime):
|
|
75
|
+
return iso_utc(value)
|
|
76
|
+
if isinstance(value, date):
|
|
77
|
+
return value.isoformat() + "T00:00:00Z"
|
|
78
|
+
return str(value)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def compact(fields: Mapping[str, Any]) -> Dict[str, Any]:
|
|
82
|
+
"""Corpo só com o que o usuário informou (``None`` fica e vira ``null``)."""
|
|
83
|
+
return {k: v for k, v in fields.items() if not isinstance(v, Unset)}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _json_default(value: Any) -> Any:
|
|
87
|
+
if isinstance(value, datetime):
|
|
88
|
+
return iso_utc(value)
|
|
89
|
+
if isinstance(value, date):
|
|
90
|
+
return value.isoformat()
|
|
91
|
+
if isinstance(value, uuid.UUID):
|
|
92
|
+
return str(value)
|
|
93
|
+
if isinstance(value, (set, frozenset, tuple)):
|
|
94
|
+
return list(value)
|
|
95
|
+
raise TypeError(f"valor não serializável em JSON: {type(value).__name__}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_retry_after(raw: Optional[str]) -> Optional[float]:
|
|
99
|
+
"""``Retry-After`` em segundos (número ou data HTTP); ``None`` se ausente/inválido."""
|
|
100
|
+
if not raw:
|
|
101
|
+
return None
|
|
102
|
+
raw = raw.strip()
|
|
103
|
+
try:
|
|
104
|
+
seconds = float(raw)
|
|
105
|
+
except ValueError:
|
|
106
|
+
try:
|
|
107
|
+
when = parsedate_to_datetime(raw)
|
|
108
|
+
except (TypeError, ValueError, IndexError):
|
|
109
|
+
return None
|
|
110
|
+
if when is None:
|
|
111
|
+
return None
|
|
112
|
+
if when.tzinfo is None:
|
|
113
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
114
|
+
seconds = (when - datetime.now(timezone.utc)).total_seconds()
|
|
115
|
+
if seconds != seconds: # NaN
|
|
116
|
+
return None
|
|
117
|
+
seconds = max(0.0, seconds)
|
|
118
|
+
return int(seconds) if float(seconds).is_integer() else seconds
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── erros ───────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _decode_json(raw: bytes) -> Tuple[Any, str]:
|
|
125
|
+
text = raw.decode("utf-8", "replace") if raw else ""
|
|
126
|
+
if not text.strip():
|
|
127
|
+
return None, text
|
|
128
|
+
try:
|
|
129
|
+
return json.loads(text), text
|
|
130
|
+
except ValueError:
|
|
131
|
+
return None, text
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_error(
|
|
135
|
+
status: int,
|
|
136
|
+
headers: Mapping[str, str],
|
|
137
|
+
raw: bytes,
|
|
138
|
+
sent_request_id: Optional[str] = None,
|
|
139
|
+
) -> BfocusError:
|
|
140
|
+
"""Resposta fora de 2xx → erro. ``request_id``: corpo → header → o id que a SDK enviou."""
|
|
141
|
+
payload, text = _decode_json(raw)
|
|
142
|
+
code: Optional[str] = None
|
|
143
|
+
human: Optional[str] = None
|
|
144
|
+
validation: Dict[str, Any] = {}
|
|
145
|
+
request_id: Optional[str] = None
|
|
146
|
+
|
|
147
|
+
if isinstance(payload, dict):
|
|
148
|
+
err, msg = payload.get("error"), payload.get("message")
|
|
149
|
+
if isinstance(err, str) and err:
|
|
150
|
+
code = err
|
|
151
|
+
elif isinstance(msg, str) and msg:
|
|
152
|
+
code = msg
|
|
153
|
+
if isinstance(msg, str) and msg and msg != code:
|
|
154
|
+
human = msg
|
|
155
|
+
if isinstance(payload.get("validation"), dict):
|
|
156
|
+
validation = payload["validation"]
|
|
157
|
+
rid = payload.get("request_id")
|
|
158
|
+
if isinstance(rid, str) and rid:
|
|
159
|
+
request_id = rid
|
|
160
|
+
elif text.strip():
|
|
161
|
+
human = text.strip()[:200]
|
|
162
|
+
|
|
163
|
+
if not code:
|
|
164
|
+
code = f"HTTP_{status}"
|
|
165
|
+
if not request_id:
|
|
166
|
+
request_id = headers.get("X-Request-Id") or sent_request_id or None
|
|
167
|
+
retry_after =parse_retry_after(headers.get("Retry-After")) if status == 429 else None
|
|
168
|
+
required_scope = (headers.get("X-Required-Scope") or None) if status == 403 else None
|
|
169
|
+
|
|
170
|
+
message = f"{code} (HTTP {status})"
|
|
171
|
+
if human:
|
|
172
|
+
message += f": {human}"
|
|
173
|
+
if required_scope:
|
|
174
|
+
message += f" — escopo exigido: {required_scope}"
|
|
175
|
+
if validation:
|
|
176
|
+
message += " — " + "; ".join(f"{k}: {v}" for k, v in validation.items())
|
|
177
|
+
if retry_after is not None:
|
|
178
|
+
message += f" — tente de novo em {retry_after}s"
|
|
179
|
+
|
|
180
|
+
cls = error_for_status(status)
|
|
181
|
+
return cls(
|
|
182
|
+
code,
|
|
183
|
+
message,
|
|
184
|
+
status,
|
|
185
|
+
request_id=request_id,
|
|
186
|
+
validation=validation,
|
|
187
|
+
retry_after=retry_after,
|
|
188
|
+
required_scope=required_scope,
|
|
189
|
+
body=payload if payload is not None else (text or None),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ── transporte ──────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Transport:
|
|
197
|
+
"""Faz UMA chamada lógica: 1 tentativa + até ``max_retries`` novas tentativas."""
|
|
198
|
+
|
|
199
|
+
def __init__(
|
|
200
|
+
self,
|
|
201
|
+
api_key: str,
|
|
202
|
+
*,
|
|
203
|
+
base_url: str,
|
|
204
|
+
timeout: float,
|
|
205
|
+
max_retries: int,
|
|
206
|
+
sleep: Optional[Callable[[float], Any]] = None,
|
|
207
|
+
) -> None:
|
|
208
|
+
self.api_key = api_key
|
|
209
|
+
self.base_url = base_url.rstrip("/")
|
|
210
|
+
self.timeout = timeout
|
|
211
|
+
self.max_retries = max_retries
|
|
212
|
+
#: Espera entre tentativas. Substituível (os testes não dormem de verdade).
|
|
213
|
+
self.sleep: Callable[[float], Any] = sleep or time.sleep
|
|
214
|
+
|
|
215
|
+
# A espera exponencial usa ``random``; isolado para os testes poderem conferir.
|
|
216
|
+
@staticmethod
|
|
217
|
+
def backoff(attempt: int) -> float:
|
|
218
|
+
base = min(MAX_BACKOFF, 0.5 * (2 ** attempt))
|
|
219
|
+
return base + random.uniform(0, base * 0.25)
|
|
220
|
+
|
|
221
|
+
def retry_delay(self, attempt: int, retry_after: Optional[str]) -> float:
|
|
222
|
+
seconds = parse_retry_after(retry_after)
|
|
223
|
+
if seconds is not None:
|
|
224
|
+
return min(float(seconds), MAX_RETRY_AFTER)
|
|
225
|
+
return self.backoff(attempt)
|
|
226
|
+
|
|
227
|
+
def build_url(self, path: str, query: Optional[Mapping[str, Any]]) -> str:
|
|
228
|
+
url = self.base_url + API_PREFIX + path
|
|
229
|
+
if query:
|
|
230
|
+
pairs = [(k, query_value(v)) for k, v in query.items() if v is not None]
|
|
231
|
+
if pairs:
|
|
232
|
+
url += "?" + urllib.parse.urlencode(pairs, quote_via=urllib.parse.quote)
|
|
233
|
+
return url
|
|
234
|
+
|
|
235
|
+
def request(
|
|
236
|
+
self,
|
|
237
|
+
method: str,
|
|
238
|
+
path: str,
|
|
239
|
+
*,
|
|
240
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
241
|
+
body: Any = None,
|
|
242
|
+
idempotency_key: Optional[str] = None,
|
|
243
|
+
timeout: Optional[float] = None,
|
|
244
|
+
) -> Tuple[Any, Optional[Dict[str, Any]]]:
|
|
245
|
+
"""Executa a chamada e devolve ``(data, pagination)`` do envelope.
|
|
246
|
+
|
|
247
|
+
``body=None`` significa SEM corpo (o JSON ``null`` nunca é um corpo válido aqui).
|
|
248
|
+
"""
|
|
249
|
+
url = self.build_url(path, query)
|
|
250
|
+
headers = {
|
|
251
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
252
|
+
"Accept": "application/json",
|
|
253
|
+
"X-Bfocus-Client": CLIENT_ID,
|
|
254
|
+
"User-Agent": CLIENT_ID,
|
|
255
|
+
# Mesmo id em todas as tentativas desta chamada: é como o suporte correlaciona.
|
|
256
|
+
"X-Request-Id": uuid.uuid4().hex,
|
|
257
|
+
}
|
|
258
|
+
if method in WRITE_METHODS:
|
|
259
|
+
# Mesma chave em todas as tentativas: a API devolve a resposta original
|
|
260
|
+
# (Idempotent-Replayed: true) em vez de executar de novo.
|
|
261
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
262
|
+
data: Optional[bytes] = None
|
|
263
|
+
if body is not None:
|
|
264
|
+
data = json.dumps(body, ensure_ascii=False, default=_json_default).encode("utf-8")
|
|
265
|
+
headers["Content-Type"] = "application/json"
|
|
266
|
+
per_try = self.timeout if timeout is None else timeout
|
|
267
|
+
|
|
268
|
+
attempt = 0
|
|
269
|
+
while True:
|
|
270
|
+
try:
|
|
271
|
+
status, resp_headers, raw = self._send(method, url, headers, data, per_try)
|
|
272
|
+
except _NETWORK_FAILURES as exc:
|
|
273
|
+
if attempt < self.max_retries:
|
|
274
|
+
self.sleep(self.backoff(attempt))
|
|
275
|
+
attempt += 1
|
|
276
|
+
continue
|
|
277
|
+
reason = getattr(exc, "reason", None) or exc
|
|
278
|
+
raise NetworkError(
|
|
279
|
+
f"NETWORK_ERROR: falha ao falar com {self.base_url} "
|
|
280
|
+
f"({type(exc).__name__}: {reason})",
|
|
281
|
+
request_id=headers["X-Request-Id"],
|
|
282
|
+
) from exc
|
|
283
|
+
|
|
284
|
+
if 200 <= status < 300:
|
|
285
|
+
return self._unwrap(status, resp_headers, raw, headers["X-Request-Id"])
|
|
286
|
+
|
|
287
|
+
if status in RETRY_STATUSES and attempt < self.max_retries:
|
|
288
|
+
self.sleep(self.retry_delay(attempt, resp_headers.get("Retry-After")))
|
|
289
|
+
attempt += 1
|
|
290
|
+
continue
|
|
291
|
+
raise build_error(status, resp_headers, raw, headers["X-Request-Id"])
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def _send(
|
|
295
|
+
method: str,
|
|
296
|
+
url: str,
|
|
297
|
+
headers: Mapping[str, str],
|
|
298
|
+
data: Optional[bytes],
|
|
299
|
+
timeout: float,
|
|
300
|
+
) -> Tuple[int, Mapping[str, str], bytes]:
|
|
301
|
+
req = urllib.request.Request(url, data=data, headers=dict(headers), method=method)
|
|
302
|
+
try:
|
|
303
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
304
|
+
return resp.status, resp.headers, resp.read()
|
|
305
|
+
except urllib.error.HTTPError as exc: # status fora de 2xx: é resposta, não rede
|
|
306
|
+
try:
|
|
307
|
+
raw = exc.read()
|
|
308
|
+
except _NETWORK_FAILURES:
|
|
309
|
+
raw = b""
|
|
310
|
+
finally:
|
|
311
|
+
exc.close()
|
|
312
|
+
return exc.code, exc.headers, raw
|
|
313
|
+
|
|
314
|
+
@staticmethod
|
|
315
|
+
def _unwrap(
|
|
316
|
+
status: int, headers: Mapping[str, str], raw: bytes, sent_request_id: str
|
|
317
|
+
) -> Tuple[Any, Optional[Dict[str, Any]]]:
|
|
318
|
+
"""2xx → ``(data, pagination)``. Sem envelope JSON válido → ``INVALID_RESPONSE``.
|
|
319
|
+
|
|
320
|
+
Nunca devolve ``None`` calado: um proxy que responde 200 com HTML (ou um corpo vazio)
|
|
321
|
+
vira erro, não "sucesso sem dados".
|
|
322
|
+
"""
|
|
323
|
+
payload, text = _decode_json(raw)
|
|
324
|
+
if isinstance(payload, dict) and "data" in payload:
|
|
325
|
+
pagination = payload.get("pagination")
|
|
326
|
+
return payload["data"], pagination if isinstance(pagination, dict) else None
|
|
327
|
+
shown = text.strip()[:200]
|
|
328
|
+
raise BfocusError(
|
|
329
|
+
"INVALID_RESPONSE",
|
|
330
|
+
f"INVALID_RESPONSE (HTTP {status}): resposta sem o envelope JSON da API"
|
|
331
|
+
+ (f": {shown!r}" if shown else " (corpo vazio)"),
|
|
332
|
+
status,
|
|
333
|
+
request_id=headers.get("X-Request-Id") or sent_request_id,
|
|
334
|
+
body=payload if payload is not None else (text or None),
|
|
335
|
+
)
|
bfocus/_version.py
ADDED
bfocus/errors.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Erros da SDK do bFocus.
|
|
2
|
+
|
|
3
|
+
Toda resposta fora de 2xx vira um :class:`BfocusError` (ou uma subclasse, pelo status).
|
|
4
|
+
Falha de conexão/timeout vira :class:`NetworkError` (``status == 0``).
|
|
5
|
+
|
|
6
|
+
**Use** :attr:`BfocusError.code` **na sua lógica** — ele é estável (``CUSTOMER_NOT_FOUND``,
|
|
7
|
+
``INTEGRATION_SCOPE_MISSING``…). O :attr:`~BfocusError.message` é texto para humanos.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"BfocusError",
|
|
16
|
+
"AuthenticationError",
|
|
17
|
+
"PermissionDeniedError",
|
|
18
|
+
"NotFoundError",
|
|
19
|
+
"ConflictError",
|
|
20
|
+
"ValidationError",
|
|
21
|
+
"RateLimitError",
|
|
22
|
+
"ServerError",
|
|
23
|
+
"NetworkError",
|
|
24
|
+
"error_for_status",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BfocusError(Exception):
|
|
29
|
+
"""Erro devolvido pela API do bFocus (ou de rede, em :class:`NetworkError`).
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
code: Código estável do erro (``body.error``; senão ``body.message``; senão
|
|
33
|
+
``HTTP_<status>`` quando o corpo não é JSON; ``INVALID_RESPONSE`` quando um 2xx
|
|
34
|
+
chega sem o envelope JSON da API). **É nele que a sua lógica decide.**
|
|
35
|
+
message: Texto legível: o ``code`` e o contexto (status, escopo exigido, motivos).
|
|
36
|
+
status: Status HTTP (``0`` em erro de rede).
|
|
37
|
+
request_id: ``request_id`` do corpo, senão o header ``X-Request-Id``, senão o
|
|
38
|
+
``X-Request-Id`` que a SDK enviou (a API ecoa o do cliente) — sempre preenchido;
|
|
39
|
+
informe-o ao suporte.
|
|
40
|
+
validation: Mapa campo → motivo (erros de validação); ``{}`` quando não há.
|
|
41
|
+
retry_after: Segundos sugeridos pelo header ``Retry-After`` (só em 429).
|
|
42
|
+
required_scope: Escopo que faltou na chave (header ``X-Required-Scope``, só em 403).
|
|
43
|
+
body: Corpo da resposta já decodificado (dict), ou o texto cru quando não é JSON.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
code: str,
|
|
49
|
+
message: str,
|
|
50
|
+
status: int,
|
|
51
|
+
*,
|
|
52
|
+
request_id: Optional[str] = None,
|
|
53
|
+
validation: Optional[Dict[str, Any]] = None,
|
|
54
|
+
retry_after: Optional[float] = None,
|
|
55
|
+
required_scope: Optional[str] = None,
|
|
56
|
+
body: Any = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
super().__init__(message)
|
|
59
|
+
self.code = code
|
|
60
|
+
self.message = message
|
|
61
|
+
self.status = status
|
|
62
|
+
self.request_id = request_id
|
|
63
|
+
self.validation: Dict[str, Any] = dict(validation or {})
|
|
64
|
+
self.retry_after = retry_after
|
|
65
|
+
self.required_scope = required_scope
|
|
66
|
+
self.body = body
|
|
67
|
+
|
|
68
|
+
def __str__(self) -> str:
|
|
69
|
+
if self.request_id:
|
|
70
|
+
return f"{self.message} [request_id={self.request_id}]"
|
|
71
|
+
return self.message
|
|
72
|
+
|
|
73
|
+
def __repr__(self) -> str:
|
|
74
|
+
return (
|
|
75
|
+
f"{type(self).__name__}(code={self.code!r}, status={self.status!r}, "
|
|
76
|
+
f"request_id={self.request_id!r})"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AuthenticationError(BfocusError):
|
|
81
|
+
"""401 — chave ausente, inválida ou revogada."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class PermissionDeniedError(BfocusError):
|
|
85
|
+
"""403 — chave desligada, IP não liberado ou escopo faltando (veja ``required_scope``)."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class NotFoundError(BfocusError):
|
|
89
|
+
"""404 — o recurso (ou a conta) não existe."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ConflictError(BfocusError):
|
|
93
|
+
"""409 — o estado atual impede a operação (ex.: ``KB_ARTICLE_EMPTY``, ``AI_DISABLED``)."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ValidationError(BfocusError):
|
|
97
|
+
"""422 — corpo ou parâmetro inválido; os motivos por campo estão em ``validation``."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class RateLimitError(BfocusError):
|
|
101
|
+
"""429 — limite de requisições da chave; ``retry_after`` diz quanto esperar."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ServerError(BfocusError):
|
|
105
|
+
"""5xx — erro do lado da API. Informe o ``request_id``."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class NetworkError(BfocusError):
|
|
109
|
+
"""Falha de conexão ou timeout (``status == 0``, ``code == "NETWORK_ERROR"``)."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, message: str, *, request_id: Optional[str] = None) -> None:
|
|
112
|
+
super().__init__("NETWORK_ERROR", message, 0, request_id=request_id)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_BY_STATUS = {
|
|
116
|
+
401: AuthenticationError,
|
|
117
|
+
403: PermissionDeniedError,
|
|
118
|
+
404: NotFoundError,
|
|
119
|
+
409: ConflictError,
|
|
120
|
+
422: ValidationError,
|
|
121
|
+
429: RateLimitError,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def error_for_status(status: int) -> type:
|
|
126
|
+
"""Classe de erro para um status HTTP (5xx → :class:`ServerError`, resto → base)."""
|
|
127
|
+
if status in _BY_STATUS:
|
|
128
|
+
return _BY_STATUS[status]
|
|
129
|
+
if 500 <= status <= 599:
|
|
130
|
+
return ServerError
|
|
131
|
+
return BfocusError
|
bfocus/py.typed
ADDED
|
File without changes
|