Á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=[], # API v1 doesn't seem to provide attachments directly in this list
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
- try:
126
- async with httpx.AsyncClient(timeout=30.0) as client:
127
- response = await client.get(API_BASE, params=params)
128
- response.raise_for_status()
129
- data = response.json()
130
-
131
- raw_list = data.get("Listado", [])
132
- if raw_list is None: # Sometimes API returns null for Listado
133
- return []
134
-
135
- return [map_raw_to_tender(item) for item in raw_list]
136
- except Exception as e:
137
- print(f"❌ API Error fetching with params {params}: {e}")
138
- return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- try:
88
- async with httpx.AsyncClient(timeout=45.0) as client:
89
- response = await client.get(API_BASE_OC, params=params)
90
- # Handle MP API quirks: sometimes it returns 500 when no data is found for a date
91
- if response.status_code == 500:
92
- print(f"⚠️ API returned 500 for {response.url} - Likely no data for this query.")
93
- return []
94
-
95
- response.raise_for_status()
96
- data = response.json()
97
-
98
- raw_list = data.get("Listado", [])
99
- if not raw_list:
100
- return []
101
-
102
- return [map_raw_to_oc(item) for item in raw_list]
103
- except Exception as e:
104
- print(f"❌ OC API Error: {e}")
105
- return []
 
 
 
 
 
 
 
 
 
 
 
 
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