Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
This commit is contained in:
2026-10-12 00:57:31 +00:00
parent f2d4558b80
commit a386ca5b4a
165 changed files with 31313 additions and 2203 deletions

View File

@@ -14,13 +14,22 @@ References:
https://developers.mercadolibre.com.ar/es_ar/api-docs-es
"""
import logging
import time
import requests
from typing import Optional
logger = logging.getLogger(__name__)
BASE_URL = "https://api.mercadolibre.com"
AUTH_URL = "https://api.mercadolibre.com/oauth/token"
# MercadoLibre rate-limits per app/user with HTTP 429 (+ Retry-After). Retry
# with exponential backoff before giving up so a burst of listing updates
# doesn't fail the whole batch.
RATE_LIMIT_MAX_RETRIES = 4
RATE_LIMIT_MAX_SLEEP = 60.0
class MeliError(Exception):
def __init__(self, message, status_code=None, response_body=None):
@@ -40,8 +49,10 @@ class MeliService:
refresh_token: Optional[str] = None,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
on_refresh=None,
):
self.access_token = access_token
self.on_refresh = on_refresh
self.refresh_token = refresh_token
self.client_id = client_id
self.client_secret = client_secret
@@ -50,6 +61,21 @@ class MeliService:
# ─── Low-level request ───────────────────────────────────────────────
@staticmethod
def _rate_limit_sleep(resp, attempt: int) -> float:
"""Seconds to wait before retrying a 429.
Honors the Retry-After header when present, otherwise exponential
backoff (1, 2, 4, ...) capped at RATE_LIMIT_MAX_SLEEP.
"""
header = resp.headers.get("Retry-After")
if header:
try:
return max(0.0, min(float(header), RATE_LIMIT_MAX_SLEEP))
except (TypeError, ValueError):
pass
return min(2.0 ** attempt, RATE_LIMIT_MAX_SLEEP)
def _request(
self,
method: str,
@@ -57,11 +83,32 @@ class MeliService:
params: Optional[dict] = None,
json_payload: Optional[dict] = None,
retry_on_401: bool = True,
max_rate_limit_retries: int = RATE_LIMIT_MAX_RETRIES,
) -> dict:
url = f"{BASE_URL}{path}"
resp = self._session.request(
method, url, params=params, json=json_payload, timeout=30
)
# 429 (rate limit) is retried with backoff; every other status is
# handled below. The 401 refresh path only runs once per call.
attempt = 0
while True:
resp = self._session.request(
method, url, params=params, json=json_payload, timeout=30
)
if resp.status_code != 429:
break
if attempt >= max_rate_limit_retries:
raise MeliError(
f"Meli API rate limit exceeded (429) after {attempt} retries: {resp.text}",
status_code=429,
response_body=resp.text,
)
delay = self._rate_limit_sleep(resp, attempt)
logger.warning(
"Meli 429 on %s %s — retry %s/%s in %.1fs",
method, path, attempt + 1, max_rate_limit_retries, delay,
)
time.sleep(delay)
attempt += 1
if resp.status_code == 401 and retry_on_401 and self.refresh_token:
self._refresh_token()
@@ -106,15 +153,31 @@ class MeliService:
}
resp = requests.post(AUTH_URL, data=payload, timeout=30)
if not resp.ok:
body = (resp.text or '')
# Credenciales de la app rechazadas: el client_secret cambió en el
# dev center de MercadoLibre — hay que actualizarlo, no reconectar.
if 'invalid_client' in body:
raise MeliAuthError(
"Las credenciales de la app de MercadoLibre (Client ID/Secret) fueron "
"rechazadas. Verifica que el Client Secret en Configuración → MercadoLibre "
"sea el vigente en el dev center (probablemente se regeneró).",
status_code=resp.status_code, response_body=body,
)
raise MeliAuthError(
f"Token refresh failed: {resp.status_code} {resp.text}",
f"Token refresh failed: {resp.status_code} {body}",
status_code=resp.status_code,
response_body=resp.text,
response_body=body,
)
data = resp.json()
self.access_token = data["access_token"]
if "refresh_token" in data:
self.refresh_token = data["refresh_token"]
# Persist rotated tokens so the connection survives restarts/rotation
if self.on_refresh:
try:
self.on_refresh(data)
except Exception:
pass # never break the request because persistence failed
return data
# ─── Auth / User ─────────────────────────────────────────────────────
@@ -237,6 +300,35 @@ class MeliService:
def close_item(self, item_id: str) -> dict:
return self.update_item(item_id, {"status": "closed"})
# ─── Item description ────────────────────────────────────────────────
#
# ML keeps the description in a sub-resource: POST /items/{id}/description
# creates it (a freshly published item has none) and PUT updates it.
def get_item_description(self, item_id: str) -> dict:
return self._request("GET", f"/items/{item_id}/description")
def set_item_description(self, item_id: str, plain_text: str, create: bool = True) -> dict:
"""Create (create=True) or update (create=False) an item's description.
Falls back to the opposite verb when ML answers "description already
exists" (400/405/409) — the two endpoints are idempotent in practice.
"""
method = "POST" if create else "PUT"
fallback = "PUT" if create else "POST"
try:
return self._request(
method, f"/items/{item_id}/description",
json_payload={"plain_text": plain_text},
)
except MeliError as e:
if e.status_code in (400, 404, 405, 409):
return self._request(
fallback, f"/items/{item_id}/description",
json_payload={"plain_text": plain_text},
)
raise
# ─── Questions & Answers ─────────────────────────────────────────────
def get_questions(self, item_id: str, status: str = None, offset: int = 0, limit: int = 50) -> dict:
@@ -270,6 +362,14 @@ class MeliService:
def get_category_attributes(self, category_id: str) -> list:
return self._request("GET", f"/categories/{category_id}/attributes")
def get_listing_prices(self, site_id: str, price: float, category_id: str) -> list:
"""MeLi fee calculator: returns fee details per listing type for a price/category."""
return self._request(
"GET",
f"/sites/{site_id}/listing_prices",
params={"price": price, "category_id": category_id},
)
def get_shipping_preferences(self, user_id: str) -> dict:
return self._request("GET", f"/users/{user_id}/shipping_preferences")