Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- 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:
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user