folyo 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.
- folyo/__init__.py +94 -0
- folyo/_redact.py +26 -0
- folyo/client.py +296 -0
- folyo/errors.py +144 -0
- folyo/models.py +433 -0
- folyo/py.typed +0 -0
- folyo/resources.py +600 -0
- folyo-0.1.0.dist-info/METADATA +184 -0
- folyo-0.1.0.dist-info/RECORD +11 -0
- folyo-0.1.0.dist-info/WHEEL +4 -0
- folyo-0.1.0.dist-info/licenses/LICENSE +21 -0
folyo/__init__.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""SDK oficial de Python para la API de Folyo.
|
|
2
|
+
|
|
3
|
+
Folyo es facturacion electronica chilena (DTE + SII). Este SDK envuelve la API
|
|
4
|
+
REST de ``https://api.folyo.cl`` con un cliente tipado, manejo de errores y
|
|
5
|
+
soporte para el patron de emision asincrona.
|
|
6
|
+
|
|
7
|
+
Ejemplo minimo::
|
|
8
|
+
|
|
9
|
+
from folyo import Folyo, DTERequest, Receptor, DetalleLinea
|
|
10
|
+
|
|
11
|
+
with Folyo(api_key="<tu-api-key>") as folyo:
|
|
12
|
+
req = DTERequest(
|
|
13
|
+
tipo_dte=33,
|
|
14
|
+
receptor=Receptor(rut="12.345.678-9", razon_social="Cliente SpA"),
|
|
15
|
+
detalle=[DetalleLinea(nombre="Servicio", monto=100000)],
|
|
16
|
+
)
|
|
17
|
+
job = folyo.dte.emitir_y_esperar(req)
|
|
18
|
+
print(job.result.folio)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from ._redact import redact
|
|
24
|
+
from .client import DEFAULT_BASE_URL, USER_AGENT, Folyo, __version__
|
|
25
|
+
from .errors import (
|
|
26
|
+
FolyoAuthError,
|
|
27
|
+
FolyoConflictError,
|
|
28
|
+
FolyoConnectionError,
|
|
29
|
+
FolyoError,
|
|
30
|
+
FolyoNotFoundError,
|
|
31
|
+
FolyoQuotaError,
|
|
32
|
+
FolyoRateLimitError,
|
|
33
|
+
FolyoSiiUnavailableError,
|
|
34
|
+
FolyoValidationError,
|
|
35
|
+
)
|
|
36
|
+
from .models import (
|
|
37
|
+
AcuseRequest,
|
|
38
|
+
ApiKey,
|
|
39
|
+
ApiKeyCreated,
|
|
40
|
+
AuthTokens,
|
|
41
|
+
Cliente,
|
|
42
|
+
DescuentoGlobal,
|
|
43
|
+
DetalleLinea,
|
|
44
|
+
Documento,
|
|
45
|
+
DTERequest,
|
|
46
|
+
EmisionEncolada,
|
|
47
|
+
EmisionJob,
|
|
48
|
+
EmisionResult,
|
|
49
|
+
Empresa,
|
|
50
|
+
FoliosInfo,
|
|
51
|
+
Receptor,
|
|
52
|
+
Referencia,
|
|
53
|
+
Transporte,
|
|
54
|
+
Webhook,
|
|
55
|
+
WebhookCreated,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"__version__",
|
|
60
|
+
"DEFAULT_BASE_URL",
|
|
61
|
+
"USER_AGENT",
|
|
62
|
+
"Folyo",
|
|
63
|
+
"redact",
|
|
64
|
+
# Errores
|
|
65
|
+
"FolyoError",
|
|
66
|
+
"FolyoAuthError",
|
|
67
|
+
"FolyoQuotaError",
|
|
68
|
+
"FolyoValidationError",
|
|
69
|
+
"FolyoNotFoundError",
|
|
70
|
+
"FolyoConflictError",
|
|
71
|
+
"FolyoRateLimitError",
|
|
72
|
+
"FolyoSiiUnavailableError",
|
|
73
|
+
"FolyoConnectionError",
|
|
74
|
+
# Modelos
|
|
75
|
+
"DTERequest",
|
|
76
|
+
"Receptor",
|
|
77
|
+
"DetalleLinea",
|
|
78
|
+
"DescuentoGlobal",
|
|
79
|
+
"Referencia",
|
|
80
|
+
"Transporte",
|
|
81
|
+
"EmisionEncolada",
|
|
82
|
+
"EmisionResult",
|
|
83
|
+
"EmisionJob",
|
|
84
|
+
"Documento",
|
|
85
|
+
"FoliosInfo",
|
|
86
|
+
"Cliente",
|
|
87
|
+
"Empresa",
|
|
88
|
+
"Webhook",
|
|
89
|
+
"WebhookCreated",
|
|
90
|
+
"ApiKey",
|
|
91
|
+
"ApiKeyCreated",
|
|
92
|
+
"AcuseRequest",
|
|
93
|
+
"AuthTokens",
|
|
94
|
+
]
|
folyo/_redact.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Utilidades de redaccion de secretos.
|
|
2
|
+
|
|
3
|
+
El SDK no debe filtrar credenciales en ``repr``, logs ni telemetria. Esta
|
|
4
|
+
funcion enmascara cualquier valor sensible mostrando solo una pista minima.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
__all__ = ["redact"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def redact(value: Optional[str]) -> str:
|
|
15
|
+
"""Devuelve una version enmascarada y segura de un secreto.
|
|
16
|
+
|
|
17
|
+
- ``None`` o cadena vacia -> ``"None"``.
|
|
18
|
+
- Valores cortos -> ``"***"`` (sin pistas, no hay suficiente entropia).
|
|
19
|
+
- Valores largos -> primeros 4 caracteres + ``"***"`` (ej. ``"stri***"``),
|
|
20
|
+
util para distinguir keys sin revelar el secreto.
|
|
21
|
+
"""
|
|
22
|
+
if not value:
|
|
23
|
+
return "None"
|
|
24
|
+
if len(value) <= 8:
|
|
25
|
+
return "***"
|
|
26
|
+
return f"{value[:4]}***"
|
folyo/client.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Cliente HTTP del SDK de Folyo.
|
|
2
|
+
|
|
3
|
+
``Folyo`` envuelve ``httpx.Client``, arma los headers de autenticacion, parsea
|
|
4
|
+
el envelope ``{ok, data}`` / ``{ok, error, code}``, mapea errores a la
|
|
5
|
+
jerarquia tipada y reintenta con backoff exponencial ante 429/503 cuando es
|
|
6
|
+
seguro hacerlo.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import Any, Dict, Mapping, NoReturn, Optional, Type
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from ._redact import redact
|
|
19
|
+
from .errors import (
|
|
20
|
+
FolyoAuthError,
|
|
21
|
+
FolyoConflictError,
|
|
22
|
+
FolyoConnectionError,
|
|
23
|
+
FolyoError,
|
|
24
|
+
FolyoNotFoundError,
|
|
25
|
+
FolyoQuotaError,
|
|
26
|
+
FolyoRateLimitError,
|
|
27
|
+
FolyoSiiUnavailableError,
|
|
28
|
+
FolyoValidationError,
|
|
29
|
+
)
|
|
30
|
+
from .resources import (
|
|
31
|
+
AcuseResource,
|
|
32
|
+
ApiKeysResource,
|
|
33
|
+
ClientesResource,
|
|
34
|
+
DteResource,
|
|
35
|
+
EmpresaResource,
|
|
36
|
+
FoliosResource,
|
|
37
|
+
RcvResource,
|
|
38
|
+
WebhooksResource,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0"
|
|
42
|
+
|
|
43
|
+
DEFAULT_BASE_URL = "https://api.folyo.cl"
|
|
44
|
+
USER_AGENT = f"folyo-sdk-python/{__version__}"
|
|
45
|
+
|
|
46
|
+
# Codigos HTTP que reintentamos cuando la operacion es segura.
|
|
47
|
+
_RETRYABLE_STATUS = (429, 503)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Folyo:
|
|
51
|
+
"""Cliente sincrono de la API de Folyo.
|
|
52
|
+
|
|
53
|
+
Exige exactamente uno de ``api_key`` o ``token`` (XOR):
|
|
54
|
+
|
|
55
|
+
- ``api_key``: se envia como header ``X-API-Key`` (valor opaco, sin asumir
|
|
56
|
+
prefijo). Fija tenant y empresa. Recomendada para integraciones server-side.
|
|
57
|
+
- ``token``: JWT que se envia como ``Authorization: Bearer <token>``.
|
|
58
|
+
|
|
59
|
+
Ejemplo::
|
|
60
|
+
|
|
61
|
+
with Folyo(api_key="<tu-api-key>") as folyo:
|
|
62
|
+
job = folyo.dte.emitir(req)
|
|
63
|
+
resultado = folyo.dte.emitir_y_esperar(req)
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
api_key: API key opaca (XOR con ``token``).
|
|
67
|
+
token: JWT de sesion (XOR con ``api_key``).
|
|
68
|
+
base_url: URL base de la API (default produccion).
|
|
69
|
+
timeout: timeout por request en segundos.
|
|
70
|
+
max_retries: reintentos ante 429/503 en operaciones seguras.
|
|
71
|
+
transport: ``httpx`` transport custom (util para tests con respx/mock).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
api_key: Optional[str] = None,
|
|
77
|
+
token: Optional[str] = None,
|
|
78
|
+
*,
|
|
79
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
80
|
+
timeout: float = 30.0,
|
|
81
|
+
max_retries: int = 2,
|
|
82
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
if bool(api_key) == bool(token):
|
|
85
|
+
raise ValueError("Debe entregar exactamente uno de 'api_key' o 'token'.")
|
|
86
|
+
|
|
87
|
+
self._api_key = api_key
|
|
88
|
+
self._token = token
|
|
89
|
+
self.base_url = base_url.rstrip("/")
|
|
90
|
+
self.max_retries = max(0, int(max_retries))
|
|
91
|
+
|
|
92
|
+
self._client = httpx.Client(
|
|
93
|
+
base_url=self.base_url,
|
|
94
|
+
timeout=timeout,
|
|
95
|
+
transport=transport,
|
|
96
|
+
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Recursos
|
|
100
|
+
self.dte = DteResource(self)
|
|
101
|
+
self.folios = FoliosResource(self)
|
|
102
|
+
self.rcv = RcvResource(self)
|
|
103
|
+
self.clientes = ClientesResource(self)
|
|
104
|
+
self.empresa = EmpresaResource(self)
|
|
105
|
+
self.acuse = AcuseResource(self)
|
|
106
|
+
self.webhooks = WebhooksResource(self)
|
|
107
|
+
self.api_keys = ApiKeysResource(self)
|
|
108
|
+
|
|
109
|
+
# -- Context manager -----------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def __enter__(self) -> "Folyo":
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def __exit__(
|
|
115
|
+
self,
|
|
116
|
+
exc_type: Optional[Type[BaseException]],
|
|
117
|
+
exc: Optional[BaseException],
|
|
118
|
+
tb: Optional[TracebackType],
|
|
119
|
+
) -> None:
|
|
120
|
+
self.close()
|
|
121
|
+
|
|
122
|
+
def close(self) -> None:
|
|
123
|
+
"""Cierra el cliente HTTP subyacente."""
|
|
124
|
+
self._client.close()
|
|
125
|
+
|
|
126
|
+
# -- Repr seguro ---------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def __repr__(self) -> str:
|
|
129
|
+
auth = (
|
|
130
|
+
f"api_key={redact(self._api_key)}"
|
|
131
|
+
if self._api_key is not None
|
|
132
|
+
else f"token={redact(self._token)}"
|
|
133
|
+
)
|
|
134
|
+
return f"Folyo(base_url={self.base_url!r}, {auth})"
|
|
135
|
+
|
|
136
|
+
# -- Headers -------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def _auth_headers(self) -> Dict[str, str]:
|
|
139
|
+
if self._api_key is not None:
|
|
140
|
+
return {"X-API-Key": self._api_key}
|
|
141
|
+
return {"Authorization": f"Bearer {self._token}"}
|
|
142
|
+
|
|
143
|
+
# -- Request core --------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def _request(
|
|
146
|
+
self,
|
|
147
|
+
method: str,
|
|
148
|
+
path: str,
|
|
149
|
+
*,
|
|
150
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
151
|
+
json: Optional[Any] = None,
|
|
152
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
153
|
+
idempotency_key: Optional[str] = None,
|
|
154
|
+
sii_ambiente: Optional[str] = None,
|
|
155
|
+
expect_binary: bool = False,
|
|
156
|
+
) -> Any:
|
|
157
|
+
"""Ejecuta un request, parsea el envelope y mapea errores.
|
|
158
|
+
|
|
159
|
+
Devuelve el contenido de ``data`` (dict/list/escalar) o, si
|
|
160
|
+
``expect_binary`` es True, los bytes crudos de la respuesta. Los
|
|
161
|
+
recursos hacen el cast al modelo concreto.
|
|
162
|
+
"""
|
|
163
|
+
headers: Dict[str, str] = dict(self._auth_headers())
|
|
164
|
+
if extra_headers:
|
|
165
|
+
headers.update(extra_headers)
|
|
166
|
+
if idempotency_key is not None:
|
|
167
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
168
|
+
if sii_ambiente is not None:
|
|
169
|
+
headers["X-SII-Ambiente"] = sii_ambiente
|
|
170
|
+
|
|
171
|
+
# Solo reintentamos operaciones idempotentes: GET, o emision con
|
|
172
|
+
# Idempotency-Key (reintentar es seguro, no quema folio nuevo).
|
|
173
|
+
is_safe = method.upper() == "GET" or idempotency_key is not None
|
|
174
|
+
|
|
175
|
+
attempt = 0
|
|
176
|
+
while True:
|
|
177
|
+
try:
|
|
178
|
+
response = self._client.request(
|
|
179
|
+
method,
|
|
180
|
+
path,
|
|
181
|
+
params=params,
|
|
182
|
+
json=json,
|
|
183
|
+
headers=headers,
|
|
184
|
+
)
|
|
185
|
+
except httpx.TimeoutException as exc:
|
|
186
|
+
raise FolyoConnectionError(
|
|
187
|
+
"Timeout al conectar con la API de Folyo."
|
|
188
|
+
) from exc
|
|
189
|
+
except httpx.TransportError as exc:
|
|
190
|
+
# No incluimos str(exc): puede traer la URL con datos sensibles.
|
|
191
|
+
raise FolyoConnectionError(
|
|
192
|
+
"No se pudo conectar con la API de Folyo."
|
|
193
|
+
) from exc
|
|
194
|
+
|
|
195
|
+
if response.status_code in _RETRYABLE_STATUS and is_safe and attempt < self.max_retries:
|
|
196
|
+
delay = self._retry_delay(response, attempt)
|
|
197
|
+
attempt += 1
|
|
198
|
+
time.sleep(delay)
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
return self._handle_response(response, expect_binary=expect_binary)
|
|
202
|
+
|
|
203
|
+
def _retry_delay(self, response: httpx.Response, attempt: int) -> float:
|
|
204
|
+
"""Calcula el backoff: respeta ``Retry-After`` o usa backoff exponencial."""
|
|
205
|
+
retry_after = self._parse_retry_after(response)
|
|
206
|
+
if retry_after is not None:
|
|
207
|
+
return retry_after
|
|
208
|
+
# Backoff exponencial con jitter: 0.5, 1, 2, ... (+- jitter).
|
|
209
|
+
base: float = 0.5 * float(2**attempt)
|
|
210
|
+
jitter: float = random.uniform(0, 0.25)
|
|
211
|
+
return base + jitter
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _parse_retry_after(response: httpx.Response) -> Optional[float]:
|
|
215
|
+
raw = response.headers.get("Retry-After")
|
|
216
|
+
if raw is None:
|
|
217
|
+
return None
|
|
218
|
+
try:
|
|
219
|
+
return float(raw)
|
|
220
|
+
except (TypeError, ValueError):
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
def _handle_response(self, response: httpx.Response, *, expect_binary: bool) -> Any:
|
|
224
|
+
request_id = response.headers.get("X-Request-Id") or response.headers.get(
|
|
225
|
+
"X-Request-ID"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if expect_binary and response.is_success:
|
|
229
|
+
return response.content
|
|
230
|
+
|
|
231
|
+
# Intentamos parsear el envelope JSON.
|
|
232
|
+
try:
|
|
233
|
+
body = response.json()
|
|
234
|
+
except ValueError:
|
|
235
|
+
body = None
|
|
236
|
+
|
|
237
|
+
if response.is_success:
|
|
238
|
+
if isinstance(body, dict) and "data" in body:
|
|
239
|
+
return body["data"]
|
|
240
|
+
# Algunas respuestas (ej. /health) no usan envelope.
|
|
241
|
+
return body if isinstance(body, dict) else {}
|
|
242
|
+
|
|
243
|
+
self._raise_for_error(response, body, request_id)
|
|
244
|
+
|
|
245
|
+
def _raise_for_error(
|
|
246
|
+
self,
|
|
247
|
+
response: httpx.Response,
|
|
248
|
+
body: Optional[Any],
|
|
249
|
+
request_id: Optional[str],
|
|
250
|
+
) -> NoReturn:
|
|
251
|
+
status = response.status_code
|
|
252
|
+
code: Optional[str] = None
|
|
253
|
+
message = ""
|
|
254
|
+
if isinstance(body, dict):
|
|
255
|
+
raw_code = body.get("code")
|
|
256
|
+
code = raw_code if isinstance(raw_code, str) else None
|
|
257
|
+
raw_msg = body.get("error")
|
|
258
|
+
message = raw_msg if isinstance(raw_msg, str) else ""
|
|
259
|
+
if not message:
|
|
260
|
+
message = f"La API respondio con HTTP {status}."
|
|
261
|
+
|
|
262
|
+
retry_after = self._parse_retry_after(response)
|
|
263
|
+
|
|
264
|
+
if status == 429:
|
|
265
|
+
raise FolyoRateLimitError(
|
|
266
|
+
message,
|
|
267
|
+
status=status,
|
|
268
|
+
code=code or "RATE_LIMITED",
|
|
269
|
+
request_id=request_id,
|
|
270
|
+
retry_after=retry_after,
|
|
271
|
+
)
|
|
272
|
+
if status in (502, 503, 504):
|
|
273
|
+
raise FolyoSiiUnavailableError(
|
|
274
|
+
message,
|
|
275
|
+
status=status,
|
|
276
|
+
code=code,
|
|
277
|
+
request_id=request_id,
|
|
278
|
+
retry_after=retry_after,
|
|
279
|
+
)
|
|
280
|
+
if status in (401, 403):
|
|
281
|
+
raise FolyoAuthError(message, status=status, code=code, request_id=request_id)
|
|
282
|
+
if status == 402:
|
|
283
|
+
raise FolyoQuotaError(message, status=status, code=code, request_id=request_id)
|
|
284
|
+
if status == 404:
|
|
285
|
+
raise FolyoNotFoundError(message, status=status, code=code, request_id=request_id)
|
|
286
|
+
if status == 409:
|
|
287
|
+
raise FolyoConflictError(message, status=status, code=code, request_id=request_id)
|
|
288
|
+
if status in (400, 422):
|
|
289
|
+
raise FolyoValidationError(message, status=status, code=code, request_id=request_id)
|
|
290
|
+
|
|
291
|
+
# PLAN_LIMIT/PLAN_REQUIRED pueden llegar como 403 (ya cubierto) pero por
|
|
292
|
+
# si el server cambia, mapeamos por codigo a quota.
|
|
293
|
+
if code in ("PLAN_LIMIT", "PLAN_REQUIRED", "PAYMENT_REQUIRED"):
|
|
294
|
+
raise FolyoQuotaError(message, status=status, code=code, request_id=request_id)
|
|
295
|
+
|
|
296
|
+
raise FolyoError(message, status=status, code=code, request_id=request_id)
|
folyo/errors.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Jerarquia de errores del SDK de Folyo.
|
|
2
|
+
|
|
3
|
+
Los errores NUNCA incluyen el cuerpo de la respuesta: este puede contener datos
|
|
4
|
+
sensibles (la clave tributaria del SII, el contenido de un .pfx, el XML firmado
|
|
5
|
+
o el secret de un webhook). Solo se exponen el mensaje sanitizado del servidor,
|
|
6
|
+
el codigo estable, el status HTTP y el request id (si el servidor lo entrega).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"FolyoError",
|
|
15
|
+
"FolyoAuthError",
|
|
16
|
+
"FolyoQuotaError",
|
|
17
|
+
"FolyoValidationError",
|
|
18
|
+
"FolyoNotFoundError",
|
|
19
|
+
"FolyoConflictError",
|
|
20
|
+
"FolyoRateLimitError",
|
|
21
|
+
"FolyoSiiUnavailableError",
|
|
22
|
+
"FolyoConnectionError",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FolyoError(Exception):
|
|
27
|
+
"""Error base de todos los errores del SDK.
|
|
28
|
+
|
|
29
|
+
Atributos:
|
|
30
|
+
message: mensaje legible y sanitizado del servidor (sin datos sensibles).
|
|
31
|
+
status: codigo HTTP de la respuesta (None en errores de conexion).
|
|
32
|
+
code: codigo estable de error del servidor (ej. ``PLAN_LIMIT``).
|
|
33
|
+
request_id: identificador de la peticion para soporte (si el server lo da).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
message: str,
|
|
39
|
+
*,
|
|
40
|
+
status: Optional[int] = None,
|
|
41
|
+
code: Optional[str] = None,
|
|
42
|
+
request_id: Optional[str] = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.message = message
|
|
46
|
+
self.status = status
|
|
47
|
+
self.code = code
|
|
48
|
+
self.request_id = request_id
|
|
49
|
+
|
|
50
|
+
def __str__(self) -> str:
|
|
51
|
+
parts = [self.message or self.__class__.__name__]
|
|
52
|
+
meta = []
|
|
53
|
+
if self.status is not None:
|
|
54
|
+
meta.append(f"status={self.status}")
|
|
55
|
+
if self.code:
|
|
56
|
+
meta.append(f"code={self.code}")
|
|
57
|
+
if self.request_id:
|
|
58
|
+
meta.append(f"request_id={self.request_id}")
|
|
59
|
+
if meta:
|
|
60
|
+
parts.append(f"({', '.join(meta)})")
|
|
61
|
+
return " ".join(parts)
|
|
62
|
+
|
|
63
|
+
def __repr__(self) -> str:
|
|
64
|
+
# No exponemos el cuerpo de la respuesta: solo metadatos seguros.
|
|
65
|
+
return (
|
|
66
|
+
f"{self.__class__.__name__}(message={self.message!r}, "
|
|
67
|
+
f"status={self.status!r}, code={self.code!r}, request_id={self.request_id!r})"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class FolyoAuthError(FolyoError):
|
|
72
|
+
"""Fallo de autenticacion/autorizacion (401/403).
|
|
73
|
+
|
|
74
|
+
Cubre ``UNAUTHORIZED``, ``INVALID_TOKEN``, ``TOKEN_EXPIRED``,
|
|
75
|
+
``PASSWORD_CHANGED``, ``FORBIDDEN``, ``INSUFFICIENT_SCOPE``,
|
|
76
|
+
``ACCOUNT_SUSPENDED``.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class FolyoQuotaError(FolyoError):
|
|
81
|
+
"""Limite de plan o pago requerido (402/403).
|
|
82
|
+
|
|
83
|
+
Cubre ``PLAN_LIMIT``, ``PLAN_REQUIRED`` y ``PAYMENT_REQUIRED``.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class FolyoValidationError(FolyoError):
|
|
88
|
+
"""Peticion invalida (400): body, params o headers mal formados."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FolyoNotFoundError(FolyoError):
|
|
92
|
+
"""Recurso no encontrado (404)."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class FolyoConflictError(FolyoError):
|
|
96
|
+
"""Conflicto (409).
|
|
97
|
+
|
|
98
|
+
Incluye ``IDEMPOTENCY_KEY_CONFLICT`` (misma Idempotency-Key con cuerpo
|
|
99
|
+
distinto) y ``TIMBRAJE_NO_AUTORIZADO``.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class FolyoRateLimitError(FolyoError):
|
|
104
|
+
"""Demasiadas peticiones (429).
|
|
105
|
+
|
|
106
|
+
Expone ``retry_after`` (segundos sugeridos por el header ``Retry-After``).
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
message: str,
|
|
112
|
+
*,
|
|
113
|
+
status: Optional[int] = None,
|
|
114
|
+
code: Optional[str] = None,
|
|
115
|
+
request_id: Optional[str] = None,
|
|
116
|
+
retry_after: Optional[float] = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
super().__init__(message, status=status, code=code, request_id=request_id)
|
|
119
|
+
self.retry_after = retry_after
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class FolyoSiiUnavailableError(FolyoError):
|
|
123
|
+
"""SII o un servicio aguas abajo no disponible (502/503/504).
|
|
124
|
+
|
|
125
|
+
Cubre ``SII_UNAVAILABLE``, ``SII_TIMEOUT``, ``SII_AUTH_FAILED``,
|
|
126
|
+
``SII_ERROR``, ``SII_CAPTCHA_REQUIRED``, ``FOLIOS_REQUESTING`` y
|
|
127
|
+
``QUEUE_UNAVAILABLE``. Expone ``retry_after`` cuando el server lo entrega.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
message: str,
|
|
133
|
+
*,
|
|
134
|
+
status: Optional[int] = None,
|
|
135
|
+
code: Optional[str] = None,
|
|
136
|
+
request_id: Optional[str] = None,
|
|
137
|
+
retry_after: Optional[float] = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
super().__init__(message, status=status, code=code, request_id=request_id)
|
|
140
|
+
self.retry_after = retry_after
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class FolyoConnectionError(FolyoError):
|
|
144
|
+
"""No se pudo conectar con la API (timeout de red, DNS, TLS, etc.)."""
|