lex-api-mx-client 0.2.0__tar.gz → 0.3.0__tar.gz

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.
@@ -90,6 +90,8 @@ supabase/.branches/
90
90
  # ----------------------------------------------------------------------
91
91
  .vscode/
92
92
  .idea/
93
+ # Capturas y registros del plugin de Playwright de Claude Code
94
+ .playwright-mcp/
93
95
  *.swp
94
96
  *.swo
95
97
  .DS_Store
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lex-api-mx-client
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Cliente Python de Lex API MX: legislación federal mexicana vigente con fuente, checksum y vigencia
5
5
  Project-URL: Homepage, https://lexapi.mx
6
6
  Project-URL: Documentation, https://lexapi.mx/docs
@@ -50,7 +50,14 @@ with LexApiClient(base_url="https://api.lexapi.mx", api_key="tu_clave") as lex:
50
50
 
51
51
  `resolve` y `map` aceptan `idempotency_key`: un reintento con la misma clave no
52
52
  se cobra dos veces. Los errores HTTP se lanzan como `LexApiError` con
53
- `status_code`, `detail`, `request_id` y `body`; `402` significa saldo agotado.
53
+ `status_code`, `detail`, `request_id`, `body` y `retry_after`; `402` significa
54
+ saldo agotado.
55
+
56
+ Un `429` (límite de peticiones) no se cobra: el cliente espera los segundos de
57
+ `Retry-After` y reintenta solo, hasta `max_retries=2` veces, cuando la espera no
58
+ pasa de `max_retry_wait=30.0` segundos. Una espera mayor, como el límite diario
59
+ del plan gratuito, se lanza enseguida como `LexApiError` con `retry_after`.
60
+ `LexApiClient(..., max_retries=0)` desactiva los reintentos.
54
61
 
55
62
  Para obtener una clave, crea tu cuenta en
56
63
  [lexapi.mx/#cuenta](https://lexapi.mx/#cuenta). Referencia de la API:
@@ -26,7 +26,14 @@ with LexApiClient(base_url="https://api.lexapi.mx", api_key="tu_clave") as lex:
26
26
 
27
27
  `resolve` y `map` aceptan `idempotency_key`: un reintento con la misma clave no
28
28
  se cobra dos veces. Los errores HTTP se lanzan como `LexApiError` con
29
- `status_code`, `detail`, `request_id` y `body`; `402` significa saldo agotado.
29
+ `status_code`, `detail`, `request_id`, `body` y `retry_after`; `402` significa
30
+ saldo agotado.
31
+
32
+ Un `429` (límite de peticiones) no se cobra: el cliente espera los segundos de
33
+ `Retry-After` y reintenta solo, hasta `max_retries=2` veces, cuando la espera no
34
+ pasa de `max_retry_wait=30.0` segundos. Una espera mayor, como el límite diario
35
+ del plan gratuito, se lanza enseguida como `LexApiError` con `retry_after`.
36
+ `LexApiClient(..., max_retries=0)` desactiva los reintentos.
30
37
 
31
38
  Para obtener una clave, crea tu cuenta en
32
39
  [lexapi.mx/#cuenta](https://lexapi.mx/#cuenta). Referencia de la API:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lex-api-mx-client"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Cliente Python de Lex API MX: legislación federal mexicana vigente con fuente, checksum y vigencia"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,4 +1,4 @@
1
1
  from .client import LexApiClient, LexApiError
2
2
 
3
3
  __all__ = ["LexApiClient", "LexApiError"]
4
- __version__ = "0.2.0"
4
+ __version__ = "0.3.0"
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import time
3
4
  from datetime import date
4
5
  from typing import Any
5
6
  from urllib.parse import quote
@@ -14,17 +15,34 @@ class LexApiError(RuntimeError):
14
15
  message: str,
15
16
  request_id: str | None = None,
16
17
  body: Any = None,
18
+ retry_after: float | None = None,
17
19
  ):
18
20
  self.status_code = status_code
19
21
  self.request_id = request_id
20
22
  self.detail = message
21
23
  self.body = body
24
+ self.retry_after = retry_after
22
25
  suffix = f" request_id={request_id}" if request_id else ""
23
26
  super().__init__(f"Lex API MX returned {status_code}: {message}{suffix}")
24
27
 
25
28
 
29
+ def _retry_after_seconds(response: httpx.Response) -> float | None:
30
+ """Segundos de `Retry-After`; la API los envía en cada 429 (no usa fechas HTTP)."""
31
+ try:
32
+ seconds = float(response.headers.get("Retry-After", ""))
33
+ except ValueError:
34
+ return None
35
+ return seconds if seconds >= 0 else None
36
+
37
+
26
38
  class LexApiClient:
27
- """Synchronous client for the stable Lex API MX integration surface."""
39
+ """Synchronous client for the stable Lex API MX integration surface.
40
+
41
+ Un 429 con `Retry-After` de hasta `max_retry_wait` segundos se reintenta solo, hasta
42
+ `max_retries` veces: la API rechaza la petición antes de procesarla y no la cobra. Una
43
+ espera mayor (el límite diario del plan gratuito) se lanza enseguida como `LexApiError`
44
+ con `retry_after`.
45
+ """
28
46
 
29
47
  def __init__(
30
48
  self,
@@ -34,11 +52,17 @@ class LexApiClient:
34
52
  timeout: float = 30.0,
35
53
  transport: httpx.BaseTransport | None = None,
36
54
  user_agent: str | None = None,
55
+ max_retries: int = 2,
56
+ max_retry_wait: float = 30.0,
37
57
  ) -> None:
38
58
  if not base_url.strip():
39
59
  raise ValueError("base_url is required")
40
60
  if not api_key.strip():
41
61
  raise ValueError("api_key is required")
62
+ if max_retries < 0:
63
+ raise ValueError("max_retries must be >= 0")
64
+ self._max_retries = max_retries
65
+ self._max_retry_wait = max_retry_wait
42
66
  if user_agent is None:
43
67
  from . import __version__ # importación tardía: el paquete importa este módulo
44
68
 
@@ -171,12 +195,28 @@ class LexApiClient:
171
195
  idempotency_key: str | None = None,
172
196
  ) -> dict[str, Any]:
173
197
  headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
174
- response = self._client.request(method, path, json=json, params=params, headers=headers)
198
+ retries = 0
199
+ while True:
200
+ response = self._client.request(method, path, json=json, params=params, headers=headers)
201
+ retry_after = _retry_after_seconds(response) if response.status_code == 429 else None
202
+ if (
203
+ retry_after is None
204
+ or retries >= self._max_retries
205
+ or retry_after > self._max_retry_wait
206
+ ):
207
+ break
208
+ retries += 1
209
+ time.sleep(retry_after)
175
210
  if response.is_error:
176
211
  body: Any = None
177
212
  try:
178
213
  body = response.json()
179
- message = str(body.get("detail", body)) if isinstance(body, dict) else str(body)
214
+ detail = body.get("detail", body) if isinstance(body, dict) else body
215
+ # Los 402 y 403 de plan traen {"code", "message", "upgrade_url"}.
216
+ if isinstance(detail, dict) and detail.get("message"):
217
+ message = str(detail["message"])
218
+ else:
219
+ message = str(detail)
180
220
  except ValueError:
181
221
  message = response.text or response.reason_phrase
182
222
  raise LexApiError(
@@ -184,6 +224,7 @@ class LexApiClient:
184
224
  message,
185
225
  response.headers.get("X-Request-ID"),
186
226
  body,
227
+ retry_after,
187
228
  )
188
229
  try:
189
230
  value = response.json()