Álvaro Valenzuela Valdes commited on
Commit ·
9f7d194
1
Parent(s): a71b4cd
fix: implement rate-limiting and retry logic for Mercado Público APIs
Browse files
backend/app/services/mercado_publico.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
|
|
| 1 |
import httpx
|
| 2 |
from typing import List, Optional, Dict, Any
|
| 3 |
from app.config import settings
|
| 4 |
from app.schemas.tender import Tender, TenderItem
|
| 5 |
from datetime import datetime
|
| 6 |
|
|
|
|
|
|
|
|
|
|
| 7 |
API_BASE = "https://api.mercadopublico.cl/servicios/v1/publico/licitaciones.json"
|
| 8 |
|
| 9 |
# Constants from documentation
|
|
@@ -73,7 +77,6 @@ TIME_UNITS = {
|
|
| 73 |
|
| 74 |
def map_raw_to_tender(item: Dict[str, Any]) -> Tender:
|
| 75 |
"""Maps raw API item to Tender schema."""
|
| 76 |
-
# Handle Items
|
| 77 |
items_list = []
|
| 78 |
raw_items = item.get("Items", {})
|
| 79 |
if isinstance(raw_items, dict) and "Listado" in raw_items:
|
|
@@ -88,7 +91,6 @@ def map_raw_to_tender(item: Dict[str, Any]) -> Tender:
|
|
| 88 |
unit=i.get("UnidadMedida", "")
|
| 89 |
))
|
| 90 |
|
| 91 |
-
# Handle dates
|
| 92 |
fechas = item.get("Fechas", {})
|
| 93 |
closing_date = fechas.get("FechaCierre") or item.get("FechaCierre")
|
| 94 |
pub_date = fechas.get("FechaPublicacion")
|
|
@@ -110,32 +112,50 @@ def map_raw_to_tender(item: Dict[str, Any]) -> Tender:
|
|
| 110 |
region=item.get("Comprador", {}).get("RegionUnidad", "Nacional"),
|
| 111 |
sector="Public",
|
| 112 |
items=items_list,
|
| 113 |
-
attachments=[],
|
| 114 |
raw_data=item
|
| 115 |
)
|
| 116 |
|
| 117 |
-
async def _fetch(params: Dict[str, str]) -> List[Tender]:
|
| 118 |
-
"""Helper to perform the actual API request."""
|
| 119 |
if not settings.mercado_publico_ticket:
|
| 120 |
print("⚠️ No Mercado Público Ticket configured.")
|
| 121 |
return []
|
| 122 |
|
| 123 |
params["ticket"] = settings.mercado_publico_ticket
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
async def get_active_tenders() -> List[Tender]:
|
| 141 |
"""Shows all tenders published on the day of the query."""
|
|
@@ -146,35 +166,24 @@ async def get_tenders_by_date(date: str) -> List[Tender]:
|
|
| 146 |
return await _fetch({"fecha": date})
|
| 147 |
|
| 148 |
async def get_tenders_by_status_and_date(status: str, date: Optional[str] = None) -> List[Tender]:
|
| 149 |
-
"""
|
| 150 |
-
Fetches tenders by status and date.
|
| 151 |
-
Status can be: publicada, cerrada, desierta, adjudicada, revocada, suspendida, todos.
|
| 152 |
-
"""
|
| 153 |
params = {"estado": status}
|
| 154 |
if date:
|
| 155 |
params["fecha"] = date
|
| 156 |
return await _fetch(params)
|
| 157 |
|
| 158 |
async def get_tender_by_code(code: str) -> Optional[Tender]:
|
| 159 |
-
"""Fetches detailed information for a specific tender code."""
|
| 160 |
results = await _fetch({"codigo": code})
|
| 161 |
return results[0] if results else None
|
| 162 |
|
| 163 |
async def get_tenders_by_provider(provider_code: str, date: str) -> List[Tender]:
|
| 164 |
-
"""Fetches tenders for a specific provider on a specific date."""
|
| 165 |
return await _fetch({"CodigoProveedor": provider_code, "fecha": date})
|
| 166 |
|
| 167 |
async def get_tenders_by_org(org_code: str, date: str) -> List[Tender]:
|
| 168 |
-
"""Fetches tenders for a specific public organization on a specific date."""
|
| 169 |
return await _fetch({"CodigoOrganismo": org_code, "fecha": date})
|
| 170 |
|
| 171 |
async def fetch_tenders(keyword: Optional[str] = None, date: Optional[str] = None) -> List[Tender]:
|
| 172 |
-
"""
|
| 173 |
-
Legacy/Wrapper function for general search.
|
| 174 |
-
"""
|
| 175 |
search_date = date if date else datetime.now().strftime("%d%m%Y")
|
| 176 |
|
| 177 |
-
# We use active tenders as default if no date is provided
|
| 178 |
if not date:
|
| 179 |
tenders = await get_active_tenders()
|
| 180 |
else:
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
import httpx
|
| 3 |
from typing import List, Optional, Dict, Any
|
| 4 |
from app.config import settings
|
| 5 |
from app.schemas.tender import Tender, TenderItem
|
| 6 |
from datetime import datetime
|
| 7 |
|
| 8 |
+
# Global semaphore to avoid "peticiones simultáneas" error from MP API
|
| 9 |
+
mp_api_semaphore = asyncio.Semaphore(1)
|
| 10 |
+
|
| 11 |
API_BASE = "https://api.mercadopublico.cl/servicios/v1/publico/licitaciones.json"
|
| 12 |
|
| 13 |
# Constants from documentation
|
|
|
|
| 77 |
|
| 78 |
def map_raw_to_tender(item: Dict[str, Any]) -> Tender:
|
| 79 |
"""Maps raw API item to Tender schema."""
|
|
|
|
| 80 |
items_list = []
|
| 81 |
raw_items = item.get("Items", {})
|
| 82 |
if isinstance(raw_items, dict) and "Listado" in raw_items:
|
|
|
|
| 91 |
unit=i.get("UnidadMedida", "")
|
| 92 |
))
|
| 93 |
|
|
|
|
| 94 |
fechas = item.get("Fechas", {})
|
| 95 |
closing_date = fechas.get("FechaCierre") or item.get("FechaCierre")
|
| 96 |
pub_date = fechas.get("FechaPublicacion")
|
|
|
|
| 112 |
region=item.get("Comprador", {}).get("RegionUnidad", "Nacional"),
|
| 113 |
sector="Public",
|
| 114 |
items=items_list,
|
| 115 |
+
attachments=[],
|
| 116 |
raw_data=item
|
| 117 |
)
|
| 118 |
|
| 119 |
+
async def _fetch(params: Dict[str, str], retries: int = 3) -> List[Tender]:
|
| 120 |
+
"""Helper to perform the actual API request with rate limit handling."""
|
| 121 |
if not settings.mercado_publico_ticket:
|
| 122 |
print("⚠️ No Mercado Público Ticket configured.")
|
| 123 |
return []
|
| 124 |
|
| 125 |
params["ticket"] = settings.mercado_publico_ticket
|
| 126 |
|
| 127 |
+
async with mp_api_semaphore:
|
| 128 |
+
for attempt in range(retries):
|
| 129 |
+
try:
|
| 130 |
+
async with httpx.AsyncClient(timeout=45.0) as client:
|
| 131 |
+
response = await client.get(API_BASE, params=params)
|
| 132 |
+
|
| 133 |
+
if response.status_code == 500:
|
| 134 |
+
print(f"⚠️ API 500 for {response.url} - Likely no data or MP glitch.")
|
| 135 |
+
return []
|
| 136 |
+
|
| 137 |
+
response.raise_for_status()
|
| 138 |
+
data = response.json()
|
| 139 |
+
|
| 140 |
+
# Check for "peticiones simultáneas" error in the payload
|
| 141 |
+
if data.get("Mensaje") and "simultáneas" in data.get("Mensaje", ""):
|
| 142 |
+
wait_time = (attempt + 1) * 2
|
| 143 |
+
print(f"🔄 Concurrent request error. Retrying in {wait_time}s... (Attempt {attempt+1}/{retries})")
|
| 144 |
+
await asyncio.sleep(wait_time)
|
| 145 |
+
continue
|
| 146 |
+
|
| 147 |
+
raw_list = data.get("Listado", [])
|
| 148 |
+
if raw_list is None:
|
| 149 |
+
return []
|
| 150 |
+
|
| 151 |
+
return [map_raw_to_tender(item) for item in raw_list]
|
| 152 |
+
except Exception as e:
|
| 153 |
+
print(f"❌ API Error (Attempt {attempt+1}): {e}")
|
| 154 |
+
if attempt < retries - 1:
|
| 155 |
+
await asyncio.sleep(1)
|
| 156 |
+
else:
|
| 157 |
+
return []
|
| 158 |
+
return []
|
| 159 |
|
| 160 |
async def get_active_tenders() -> List[Tender]:
|
| 161 |
"""Shows all tenders published on the day of the query."""
|
|
|
|
| 166 |
return await _fetch({"fecha": date})
|
| 167 |
|
| 168 |
async def get_tenders_by_status_and_date(status: str, date: Optional[str] = None) -> List[Tender]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
params = {"estado": status}
|
| 170 |
if date:
|
| 171 |
params["fecha"] = date
|
| 172 |
return await _fetch(params)
|
| 173 |
|
| 174 |
async def get_tender_by_code(code: str) -> Optional[Tender]:
|
|
|
|
| 175 |
results = await _fetch({"codigo": code})
|
| 176 |
return results[0] if results else None
|
| 177 |
|
| 178 |
async def get_tenders_by_provider(provider_code: str, date: str) -> List[Tender]:
|
|
|
|
| 179 |
return await _fetch({"CodigoProveedor": provider_code, "fecha": date})
|
| 180 |
|
| 181 |
async def get_tenders_by_org(org_code: str, date: str) -> List[Tender]:
|
|
|
|
| 182 |
return await _fetch({"CodigoOrganismo": org_code, "fecha": date})
|
| 183 |
|
| 184 |
async def fetch_tenders(keyword: Optional[str] = None, date: Optional[str] = None) -> List[Tender]:
|
|
|
|
|
|
|
|
|
|
| 185 |
search_date = date if date else datetime.now().strftime("%d%m%Y")
|
| 186 |
|
|
|
|
| 187 |
if not date:
|
| 188 |
tenders = await get_active_tenders()
|
| 189 |
else:
|
backend/app/services/mercado_publico_oc.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
|
|
| 1 |
import httpx
|
| 2 |
from typing import List, Optional, Dict, Any
|
| 3 |
from app.config import settings
|
| 4 |
from app.schemas.oc import PurchaseOrder, OCItem
|
| 5 |
from datetime import datetime
|
| 6 |
|
|
|
|
|
|
|
|
|
|
| 7 |
API_BASE_OC = "https://api.mercadopublico.cl/servicios/v1/publico/ordenesdecompra.json"
|
| 8 |
|
| 9 |
OC_STATUS_CODES = {
|
|
@@ -74,35 +78,46 @@ def map_raw_to_oc(item: Dict[str, Any]) -> PurchaseOrder:
|
|
| 74 |
raw_data=item
|
| 75 |
)
|
| 76 |
|
| 77 |
-
async def _fetch_oc(params: Dict[str, str]) -> List[PurchaseOrder]:
|
| 78 |
if not settings.mercado_publico_ticket:
|
| 79 |
return []
|
| 80 |
|
| 81 |
params["ticket"] = settings.mercado_publico_ticket
|
| 82 |
|
| 83 |
-
# Cleaning params: if status is "todos", it's better to omit it when fecha is present
|
| 84 |
if params.get("estado") == "todos":
|
| 85 |
del params["estado"]
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
async def get_oc_by_code(code: str) -> Optional[PurchaseOrder]:
|
| 108 |
results = await _fetch_oc({"codigo": code})
|
|
@@ -110,14 +125,10 @@ async def get_oc_by_code(code: str) -> Optional[PurchaseOrder]:
|
|
| 110 |
|
| 111 |
async def get_ocs_by_date(date: str, status: str = "todos") -> List[PurchaseOrder]:
|
| 112 |
params = {"estado": status}
|
| 113 |
-
|
| 114 |
-
# Logic based on documentation examples:
|
| 115 |
-
# If it's today, the doc suggests using estado=todos without fecha
|
| 116 |
today_str = datetime.now().strftime("%d%m%Y")
|
| 117 |
if date == today_str and status == "todos":
|
| 118 |
return await _fetch_oc({"estado": "todos"})
|
| 119 |
|
| 120 |
-
# Otherwise, use the date
|
| 121 |
params["fecha"] = date
|
| 122 |
return await _fetch_oc(params)
|
| 123 |
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
import httpx
|
| 3 |
from typing import List, Optional, Dict, Any
|
| 4 |
from app.config import settings
|
| 5 |
from app.schemas.oc import PurchaseOrder, OCItem
|
| 6 |
from datetime import datetime
|
| 7 |
|
| 8 |
+
# Global semaphore to avoid "peticiones simultáneas" error from MP API
|
| 9 |
+
mp_api_semaphore = asyncio.Semaphore(1)
|
| 10 |
+
|
| 11 |
API_BASE_OC = "https://api.mercadopublico.cl/servicios/v1/publico/ordenesdecompra.json"
|
| 12 |
|
| 13 |
OC_STATUS_CODES = {
|
|
|
|
| 78 |
raw_data=item
|
| 79 |
)
|
| 80 |
|
| 81 |
+
async def _fetch_oc(params: Dict[str, str], retries: int = 3) -> List[PurchaseOrder]:
|
| 82 |
if not settings.mercado_publico_ticket:
|
| 83 |
return []
|
| 84 |
|
| 85 |
params["ticket"] = settings.mercado_publico_ticket
|
| 86 |
|
|
|
|
| 87 |
if params.get("estado") == "todos":
|
| 88 |
del params["estado"]
|
| 89 |
|
| 90 |
+
async with mp_api_semaphore:
|
| 91 |
+
for attempt in range(retries):
|
| 92 |
+
try:
|
| 93 |
+
async with httpx.AsyncClient(timeout=45.0) as client:
|
| 94 |
+
response = await client.get(API_BASE_OC, params=params)
|
| 95 |
+
|
| 96 |
+
if response.status_code == 500:
|
| 97 |
+
print(f"⚠️ API 500 for {response.url} - Likely no data or MP glitch.")
|
| 98 |
+
return []
|
| 99 |
+
|
| 100 |
+
response.raise_for_status()
|
| 101 |
+
data = response.json()
|
| 102 |
+
|
| 103 |
+
if data.get("Mensaje") and "simultáneas" in data.get("Mensaje", ""):
|
| 104 |
+
wait_time = (attempt + 1) * 2
|
| 105 |
+
print(f"🔄 OC Concurrent request error. Retrying in {wait_time}s... (Attempt {attempt+1}/{retries})")
|
| 106 |
+
await asyncio.sleep(wait_time)
|
| 107 |
+
continue
|
| 108 |
+
|
| 109 |
+
raw_list = data.get("Listado", [])
|
| 110 |
+
if not raw_list:
|
| 111 |
+
return []
|
| 112 |
+
|
| 113 |
+
return [map_raw_to_oc(item) for item in raw_list]
|
| 114 |
+
except Exception as e:
|
| 115 |
+
print(f"❌ OC API Error (Attempt {attempt+1}): {e}")
|
| 116 |
+
if attempt < retries - 1:
|
| 117 |
+
await asyncio.sleep(1)
|
| 118 |
+
else:
|
| 119 |
+
return []
|
| 120 |
+
return []
|
| 121 |
|
| 122 |
async def get_oc_by_code(code: str) -> Optional[PurchaseOrder]:
|
| 123 |
results = await _fetch_oc({"codigo": code})
|
|
|
|
| 125 |
|
| 126 |
async def get_ocs_by_date(date: str, status: str = "todos") -> List[PurchaseOrder]:
|
| 127 |
params = {"estado": status}
|
|
|
|
|
|
|
|
|
|
| 128 |
today_str = datetime.now().strftime("%d%m%Y")
|
| 129 |
if date == today_str and status == "todos":
|
| 130 |
return await _fetch_oc({"estado": "todos"})
|
| 131 |
|
|
|
|
| 132 |
params["fecha"] = date
|
| 133 |
return await _fetch_oc(params)
|
| 134 |
|