yc1838 commited on
Commit
9001c13
·
1 Parent(s): 6b91a00

handle 429

Browse files
app.py CHANGED
@@ -16,38 +16,7 @@ import requests
16
  from lilith_agent.app import build_react_agent
17
  from lilith_agent.config import Config
18
  from lilith_agent.runner import run_agent_on_questions
19
-
20
-
21
- DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
22
-
23
-
24
- class ScoringApiClient:
25
- """Tiny client for the GAIA scoring Space (questions + file download)."""
26
-
27
- def __init__(self, api_url: str = DEFAULT_API_URL) -> None:
28
- self.api_url = api_url.rstrip("/")
29
-
30
- def get_questions(self) -> list[dict]:
31
- r = requests.get(f"{self.api_url}/questions", timeout=30)
32
- r.raise_for_status()
33
- return r.json()
34
-
35
- def download_file(self, task_id: str, dest_dir: str | Path) -> Path | None:
36
- dest_dir = Path(dest_dir)
37
- dest_dir.mkdir(parents=True, exist_ok=True)
38
- try:
39
- r = requests.get(f"{self.api_url}/files/{task_id}", timeout=60)
40
- r.raise_for_status()
41
- except requests.RequestException:
42
- return None
43
-
44
- filename = task_id
45
- cd = r.headers.get("content-disposition", "")
46
- if "filename=" in cd:
47
- filename = cd.split("filename=")[-1].strip().strip('"')
48
- out = dest_dir / filename
49
- out.write_bytes(r.content)
50
- return out
51
 
52
 
53
  class LilithAgent:
@@ -86,6 +55,8 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
86
  questions_data = agent.client.get_questions()
87
  except requests.exceptions.RequestException as e:
88
  return f"Error fetching questions: {e}", None
 
 
89
 
90
  if not questions_data:
91
  return "Fetched questions list is empty or invalid format.", None
 
16
  from lilith_agent.app import build_react_agent
17
  from lilith_agent.config import Config
18
  from lilith_agent.runner import run_agent_on_questions
19
+ from lilith_agent.scoring_client import DEFAULT_API_URL, ScoringApiClient
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
 
22
  class LilithAgent:
 
55
  questions_data = agent.client.get_questions()
56
  except requests.exceptions.RequestException as e:
57
  return f"Error fetching questions: {e}", None
58
+ if agent.client.last_warning:
59
+ print(agent.client.last_warning)
60
 
61
  if not questions_data:
62
  return "Fetched questions list is empty or invalid format.", None
src/lilith_agent/scoring_client.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import requests
9
+
10
+ DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ if TYPE_CHECKING:
15
+ from lilith_agent.gaia_dataset import GaiaDatasetClient
16
+
17
+
18
+ class ScoringApiClient:
19
+ """Client for the GAIA scoring Space with dataset fallback on transient failures."""
20
+
21
+ def __init__(
22
+ self,
23
+ api_url: str = DEFAULT_API_URL,
24
+ session: requests.Session | None = None,
25
+ dataset_client: Any | None = None,
26
+ ) -> None:
27
+ self.api_url = api_url.rstrip("/")
28
+ self.session = session or requests.Session()
29
+ self._dataset_client = dataset_client
30
+ self.last_warning: str | None = None
31
+
32
+ def get_questions(self) -> list[dict]:
33
+ try:
34
+ response = self.session.get(f"{self.api_url}/questions", timeout=30)
35
+ response.raise_for_status()
36
+ self.last_warning = None
37
+ return response.json()
38
+ except requests.RequestException as exc:
39
+ fallback = self._fallback_dataset_client_for(exc, action="fetch questions")
40
+ if fallback is None:
41
+ raise
42
+ return fallback.get_questions()
43
+
44
+ def download_file(self, task_id: str, dest_dir: str | Path) -> Path | None:
45
+ dest_dir = Path(dest_dir)
46
+ dest_dir.mkdir(parents=True, exist_ok=True)
47
+ try:
48
+ response = self.session.get(f"{self.api_url}/files/{task_id}", timeout=60)
49
+ response.raise_for_status()
50
+ except requests.RequestException as exc:
51
+ fallback = self._fallback_dataset_client_for(exc, action=f"download file for {task_id}")
52
+ if fallback is None:
53
+ return None
54
+ return fallback.download_file(task_id, dest_dir)
55
+
56
+ filename = task_id
57
+ cd = response.headers.get("content-disposition", "")
58
+ if "filename=" in cd:
59
+ filename = cd.split("filename=")[-1].strip().strip('"')
60
+ out = dest_dir / filename
61
+ out.write_bytes(response.content)
62
+ self.last_warning = None
63
+ return out
64
+
65
+ def _fallback_dataset_client_for(
66
+ self,
67
+ exc: requests.RequestException,
68
+ *,
69
+ action: str,
70
+ ) -> Any | None:
71
+ if not self._should_use_dataset_fallback(exc):
72
+ return None
73
+ try:
74
+ dataset_client = self._get_dataset_client()
75
+ except Exception:
76
+ return None
77
+
78
+ status_code = getattr(getattr(exc, "response", None), "status_code", None)
79
+ detail = f"status={status_code}" if status_code is not None else exc.__class__.__name__
80
+ self.last_warning = (
81
+ f"Scoring API unavailable while trying to {action} ({detail}); "
82
+ f"falling back to GAIA dataset."
83
+ )
84
+ log.warning(self.last_warning)
85
+ return dataset_client
86
+
87
+ def _get_dataset_client(self) -> Any:
88
+ if self._dataset_client is None:
89
+ from lilith_agent.gaia_dataset import GaiaDatasetClient
90
+
91
+ token = os.getenv("HF_TOKEN") or os.getenv("GAIA_HUGGINGFACE_API_KEY")
92
+ self._dataset_client = GaiaDatasetClient(
93
+ config=os.getenv("GAIA_DATASET_CONFIG", "2023_all"),
94
+ split=os.getenv("GAIA_DATASET_SPLIT", "test"),
95
+ level=None,
96
+ token=token,
97
+ )
98
+ return self._dataset_client
99
+
100
+ @staticmethod
101
+ def _should_use_dataset_fallback(exc: requests.RequestException) -> bool:
102
+ if isinstance(exc, (requests.Timeout, requests.ConnectionError)):
103
+ return True
104
+ status_code = getattr(getattr(exc, "response", None), "status_code", None)
105
+ return status_code in {429, 502, 503, 504}
tests/test_checkpointing.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import importlib
 
4
  import types
5
  import unittest
6
  from pathlib import Path
@@ -11,8 +12,6 @@ from lilith_agent.checkpointing import build_checkpointer
11
 
12
  class CheckpointingTests(unittest.TestCase):
13
  def test_build_checkpointer_uses_sqlite_when_available(self) -> None:
14
- tmp_path = Path(self._testMethodName)
15
-
16
  class FakeSqliteSaver:
17
  def __init__(self, conn) -> None:
18
  self.conn = conn
@@ -22,15 +21,15 @@ class CheckpointingTests(unittest.TestCase):
22
  return types.SimpleNamespace(SqliteSaver=FakeSqliteSaver)
23
  raise AssertionError(f"unexpected import: {name}")
24
 
25
- with patch.object(importlib, "import_module", side_effect=fake_import_module):
26
- saver = build_checkpointer(tmp_path)
 
 
27
 
28
- self.assertIsInstance(saver, FakeSqliteSaver)
29
- self.assertTrue((tmp_path / "threads.sqlite").exists())
30
 
31
  def test_build_checkpointer_falls_back_to_in_memory_when_sqlite_package_missing(self) -> None:
32
- tmp_path = Path(self._testMethodName)
33
-
34
  class FakeInMemorySaver:
35
  pass
36
 
@@ -41,12 +40,14 @@ class CheckpointingTests(unittest.TestCase):
41
  return types.SimpleNamespace(InMemorySaver=FakeInMemorySaver)
42
  raise AssertionError(f"unexpected import: {name}")
43
 
44
- with patch.object(importlib, "import_module", side_effect=fake_import_module):
45
- with self.assertLogs("lilith_agent.checkpointing", level="WARNING") as captured:
46
- saver = build_checkpointer(tmp_path)
 
 
47
 
48
- self.assertIsInstance(saver, FakeInMemorySaver)
49
- self.assertIn("langgraph-checkpoint-sqlite missing", "\n".join(captured.output))
50
 
51
  def test_requirements_include_langgraph_checkpoint_sqlite(self) -> None:
52
  requirements = Path("requirements.txt").read_text().splitlines()
 
1
  from __future__ import annotations
2
 
3
  import importlib
4
+ import tempfile
5
  import types
6
  import unittest
7
  from pathlib import Path
 
12
 
13
  class CheckpointingTests(unittest.TestCase):
14
  def test_build_checkpointer_uses_sqlite_when_available(self) -> None:
 
 
15
  class FakeSqliteSaver:
16
  def __init__(self, conn) -> None:
17
  self.conn = conn
 
21
  return types.SimpleNamespace(SqliteSaver=FakeSqliteSaver)
22
  raise AssertionError(f"unexpected import: {name}")
23
 
24
+ with tempfile.TemporaryDirectory() as tmpdir:
25
+ tmp_path = Path(tmpdir)
26
+ with patch.object(importlib, "import_module", side_effect=fake_import_module):
27
+ saver = build_checkpointer(tmp_path)
28
 
29
+ self.assertIsInstance(saver, FakeSqliteSaver)
30
+ self.assertTrue((tmp_path / "threads.sqlite").exists())
31
 
32
  def test_build_checkpointer_falls_back_to_in_memory_when_sqlite_package_missing(self) -> None:
 
 
33
  class FakeInMemorySaver:
34
  pass
35
 
 
40
  return types.SimpleNamespace(InMemorySaver=FakeInMemorySaver)
41
  raise AssertionError(f"unexpected import: {name}")
42
 
43
+ with tempfile.TemporaryDirectory() as tmpdir:
44
+ tmp_path = Path(tmpdir)
45
+ with patch.object(importlib, "import_module", side_effect=fake_import_module):
46
+ with self.assertLogs("lilith_agent.checkpointing", level="WARNING") as captured:
47
+ saver = build_checkpointer(tmp_path)
48
 
49
+ self.assertIsInstance(saver, FakeInMemorySaver)
50
+ self.assertIn("langgraph-checkpoint-sqlite missing", "\n".join(captured.output))
51
 
52
  def test_requirements_include_langgraph_checkpoint_sqlite(self) -> None:
53
  requirements = Path("requirements.txt").read_text().splitlines()
tests/test_scoring_client.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+ from unittest.mock import Mock, patch
7
+
8
+ import requests
9
+
10
+ from lilith_agent.scoring_client import ScoringApiClient
11
+
12
+
13
+ class ScoringApiClientTests(unittest.TestCase):
14
+ def test_get_questions_returns_api_payload_on_success(self) -> None:
15
+ response = Mock()
16
+ response.json.return_value = [{"task_id": "t1", "question": "Q"}]
17
+ response.raise_for_status.return_value = None
18
+
19
+ session = Mock()
20
+ session.get.return_value = response
21
+
22
+ client = ScoringApiClient(api_url="https://example.com", session=session)
23
+
24
+ payload = client.get_questions()
25
+
26
+ self.assertEqual(payload, [{"task_id": "t1", "question": "Q"}])
27
+ self.assertIsNone(client.last_warning)
28
+ session.get.assert_called_once_with("https://example.com/questions", timeout=30)
29
+
30
+ def test_get_questions_falls_back_to_dataset_on_429(self) -> None:
31
+ response = Mock(status_code=429, headers={})
32
+ error = requests.HTTPError("Too Many Requests", response=response)
33
+ response.raise_for_status.side_effect = error
34
+
35
+ session = Mock()
36
+ session.get.return_value = response
37
+ dataset_client = Mock()
38
+ dataset_client.get_questions.return_value = [{"task_id": "fallback", "question": "Q2"}]
39
+
40
+ client = ScoringApiClient(
41
+ api_url="https://example.com",
42
+ session=session,
43
+ dataset_client=dataset_client,
44
+ )
45
+
46
+ payload = client.get_questions()
47
+
48
+ self.assertEqual(payload, [{"task_id": "fallback", "question": "Q2"}])
49
+ self.assertIn("429", client.last_warning or "")
50
+ dataset_client.get_questions.assert_called_once_with()
51
+
52
+ def test_download_file_falls_back_to_dataset_on_429(self) -> None:
53
+ response = Mock(status_code=429, headers={})
54
+ error = requests.HTTPError("Too Many Requests", response=response)
55
+ response.raise_for_status.side_effect = error
56
+
57
+ session = Mock()
58
+ session.get.return_value = response
59
+ dataset_client = Mock()
60
+
61
+ with tempfile.TemporaryDirectory() as tmpdir:
62
+ fallback_path = Path(tmpdir) / "from-dataset.txt"
63
+ fallback_path.write_text("ok")
64
+ dataset_client.download_file.return_value = fallback_path
65
+
66
+ client = ScoringApiClient(
67
+ api_url="https://example.com",
68
+ session=session,
69
+ dataset_client=dataset_client,
70
+ )
71
+
72
+ payload = client.download_file("task-123", tmpdir)
73
+
74
+ self.assertEqual(payload, fallback_path)
75
+ self.assertIn("429", client.last_warning or "")
76
+ dataset_client.download_file.assert_called_once_with("task-123", Path(tmpdir))
77
+
78
+ def test_default_dataset_client_is_built_lazily(self) -> None:
79
+ session = Mock()
80
+ response = Mock(status_code=429, headers={})
81
+ error = requests.HTTPError("Too Many Requests", response=response)
82
+ response.raise_for_status.side_effect = error
83
+ session.get.return_value = response
84
+
85
+ dataset_instance = Mock()
86
+ dataset_instance.get_questions.return_value = []
87
+
88
+ with patch.object(ScoringApiClient, "_get_dataset_client", return_value=dataset_instance) as getter:
89
+ client = ScoringApiClient(api_url="https://example.com", session=session)
90
+ client.get_questions()
91
+
92
+ getter.assert_called_once_with()
93
+
94
+
95
+ if __name__ == "__main__":
96
+ unittest.main()