feature/error-handling

#2
by IkramMakh - opened
Files changed (2) hide show
  1. src/api_client.py +49 -7
  2. src/tools.py +6 -4
src/api_client.py CHANGED
@@ -1,13 +1,18 @@
1
  """HTTP client for the Cube.js API of Indicateurs Territoriaux."""
2
 
 
3
  import os
4
- from typing import Any
5
 
6
  import httpx
7
  from dotenv import load_dotenv
8
 
9
  load_dotenv()
10
 
 
 
 
 
11
 
12
  class CubeJsClientError(Exception):
13
  """Base exception for Cube.js client errors."""
@@ -123,6 +128,46 @@ class CubeJsClient:
123
 
124
  return response.json()
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  async def get_meta(self) -> dict[str, Any]:
127
  """Fetch the API schema metadata.
128
 
@@ -136,9 +181,7 @@ class CubeJsClient:
136
  AuthenticationError: If authentication fails.
137
  CubeJsClientError: For other API errors.
138
  """
139
- client = await self._get_client()
140
- response = await client.get("/cubejs-api/v1/meta")
141
- return await self._handle_response(response)
142
 
143
  async def load(self, query: dict[str, Any]) -> dict[str, Any]:
144
  """Execute a data query against the Cube.js API.
@@ -163,12 +206,11 @@ class CubeJsClient:
163
  ... }
164
  >>> result = await client.load(query)
165
  """
166
- client = await self._get_client()
167
- response = await client.post(
168
  "/cubejs-api/v1/load",
169
  json={"query": query},
170
  )
171
- return await self._handle_response(response)
172
 
173
  async def load_indicators_metadata(
174
  self,
 
1
  """HTTP client for the Cube.js API of Indicateurs Territoriaux."""
2
 
3
+ import asyncio
4
  import os
5
+ from typing import Any, Literal
6
 
7
  import httpx
8
  from dotenv import load_dotenv
9
 
10
  load_dotenv()
11
 
12
+ _MAX_ATTEMPTS = 3
13
+ _BACKOFF_SECONDS = (0.5, 1.0, 2.0)
14
+ _RETRYABLE_STATUS = frozenset({502, 503, 504})
15
+
16
 
17
  class CubeJsClientError(Exception):
18
  """Base exception for Cube.js client errors."""
 
128
 
129
  return response.json()
130
 
131
+ async def _request(
132
+ self,
133
+ method: Literal["GET", "POST"],
134
+ path: str,
135
+ **kwargs: Any,
136
+ ) -> dict[str, Any]:
137
+ client = await self._get_client()
138
+
139
+ for attempt in range(_MAX_ATTEMPTS):
140
+ try:
141
+ response = await client.request(method, path, **kwargs)
142
+ except httpx.TimeoutException as e:
143
+ if attempt < _MAX_ATTEMPTS - 1:
144
+ await asyncio.sleep(_BACKOFF_SECONDS[attempt])
145
+ continue
146
+ raise CubeJsClientError(
147
+ f"Indicators API did not respond within {self.timeout}s"
148
+ ) from e
149
+ except httpx.ConnectError as e:
150
+ if attempt < _MAX_ATTEMPTS - 1:
151
+ await asyncio.sleep(_BACKOFF_SECONDS[attempt])
152
+ continue
153
+ raise CubeJsClientError("Indicators API unreachable") from e
154
+ except httpx.HTTPError as e:
155
+ if attempt < _MAX_ATTEMPTS - 1:
156
+ await asyncio.sleep(_BACKOFF_SECONDS[attempt])
157
+ continue
158
+ raise CubeJsClientError(f"Indicators API network error: {e}") from e
159
+
160
+ if (
161
+ response.status_code in _RETRYABLE_STATUS
162
+ and attempt < _MAX_ATTEMPTS - 1
163
+ ):
164
+ await asyncio.sleep(_BACKOFF_SECONDS[attempt])
165
+ continue
166
+
167
+ return await self._handle_response(response)
168
+
169
+ raise CubeJsClientError("Indicators API unavailable")
170
+
171
  async def get_meta(self) -> dict[str, Any]:
172
  """Fetch the API schema metadata.
173
 
 
181
  AuthenticationError: If authentication fails.
182
  CubeJsClientError: For other API errors.
183
  """
184
+ return await self._request("GET", "/cubejs-api/v1/meta")
 
 
185
 
186
  async def load(self, query: dict[str, Any]) -> dict[str, Any]:
187
  """Execute a data query against the Cube.js API.
 
206
  ... }
207
  >>> result = await client.load(query)
208
  """
209
+ return await self._request(
210
+ "POST",
211
  "/cubejs-api/v1/load",
212
  json={"query": query},
213
  )
 
214
 
215
  async def load_indicators_metadata(
216
  self,
src/tools.py CHANGED
@@ -16,6 +16,8 @@ from dataclasses import dataclass, field
16
  from functools import wraps
17
  from typing import Any, Callable
18
 
 
 
19
  from .api_client import get_client, CubeJsClientError
20
  from .cache import fold_text, get_cache, initialize_cache, refresh_cache_if_needed
21
  from .cube_resolver import get_resolver
@@ -285,7 +287,7 @@ async def _ensure_cache_initialized() -> str | None:
285
  await refresh_cache_if_needed()
286
  except ValueError as e: # missing token
287
  return _error(f"Server misconfigured: {e}")
288
- except CubeJsClientError as e:
289
  if cache.is_initialized:
290
  return None # stale cache is better than no answer
291
  return _error(f"Indicators API unavailable: {e}", hint="Retry in a few seconds.")
@@ -416,7 +418,7 @@ async def get_indicator_details(indicator_id: str) -> str:
416
  src = SourceMetadata.from_api_response(row).model_dump(exclude_none=True)
417
  src.pop("id_indicateur", None)
418
  sources.append(src)
419
- except CubeJsClientError as e:
420
  sources_warning = f"Could not fetch sources: {e}"
421
 
422
  metadata = {
@@ -534,7 +536,7 @@ async def query_indicator_data(
534
  try:
535
  result = await client.load(query)
536
  data_rows = result.get("data", [])
537
- except CubeJsClientError as e:
538
  return _error(f"Query failed: {e}", cube=cube_name)
539
 
540
  data_points = [
@@ -631,7 +633,7 @@ async def find_territory(name: str, geographic_level: str = "") -> str:
631
  rows = await client.find_territories(
632
  cube_name, patterns["geocode"], patterns["libelle"], search, limit=TERRITORY_MAX * 3
633
  )
634
- except CubeJsClientError as e:
635
  warnings.append(f"{lv}: lookup failed ({e})")
636
  continue
637
  geocode_dim = f"{cube_name}.{patterns['geocode']}"
 
16
  from functools import wraps
17
  from typing import Any, Callable
18
 
19
+ import httpx
20
+
21
  from .api_client import get_client, CubeJsClientError
22
  from .cache import fold_text, get_cache, initialize_cache, refresh_cache_if_needed
23
  from .cube_resolver import get_resolver
 
287
  await refresh_cache_if_needed()
288
  except ValueError as e: # missing token
289
  return _error(f"Server misconfigured: {e}")
290
+ except (CubeJsClientError, httpx.HTTPError) as e:
291
  if cache.is_initialized:
292
  return None # stale cache is better than no answer
293
  return _error(f"Indicators API unavailable: {e}", hint="Retry in a few seconds.")
 
418
  src = SourceMetadata.from_api_response(row).model_dump(exclude_none=True)
419
  src.pop("id_indicateur", None)
420
  sources.append(src)
421
+ except (CubeJsClientError, httpx.HTTPError) as e:
422
  sources_warning = f"Could not fetch sources: {e}"
423
 
424
  metadata = {
 
536
  try:
537
  result = await client.load(query)
538
  data_rows = result.get("data", [])
539
+ except (CubeJsClientError, httpx.HTTPError) as e:
540
  return _error(f"Query failed: {e}", cube=cube_name)
541
 
542
  data_points = [
 
633
  rows = await client.find_territories(
634
  cube_name, patterns["geocode"], patterns["libelle"], search, limit=TERRITORY_MAX * 3
635
  )
636
+ except (CubeJsClientError, httpx.HTTPError) as e:
637
  warnings.append(f"{lv}: lookup failed ({e})")
638
  continue
639
  geocode_dim = f"{cube_name}.{patterns['geocode']}"