diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index 34067c71c..9a924bcf2 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -88,7 +88,7 @@ def check_auth(self): r = requests.get(url=url, timeout=2, headers=headers) if r.status_code != 200: self._log_error(url, {}, r) - return None + r.raise_for_status() return r.json() def get_list_organizations(self): @@ -100,7 +100,7 @@ def get_list_organizations(self): r = requests.get(url=url, timeout=2, headers=headers) if r.status_code != 200: self._log_error(url, {}, r) - return None + r.raise_for_status() return r.json() def check_organization_exists(self, organization_name: str): @@ -131,7 +131,7 @@ def create_organization(self, organization: OrganizationCreate): r = requests.post(url=url, json=payload, timeout=2, headers=headers) if r.status_code != 201: self._log_error(url, payload, r) - return None + r.raise_for_status() return r.json() def get_organization(self, organization_id): @@ -143,7 +143,7 @@ def get_organization(self, organization_id): r = requests.get(url=url, timeout=2, headers=headers) if r.status_code != 200: self._log_error(url, {}, r) - return None + r.raise_for_status() return r.json() def update_organization(self, organization: OrganizationCreate): @@ -156,7 +156,7 @@ def update_organization(self, organization: OrganizationCreate): r = requests.patch(url=url, json=payload, timeout=2, headers=headers) if r.status_code != 200: self._log_error(url, payload, r) - return None + r.raise_for_status() return r.json() def list_projects_from_organization(self, organization_id): @@ -168,7 +168,7 @@ def list_projects_from_organization(self, organization_id): r = requests.get(url=url, timeout=2, headers=headers) if r.status_code != 200: self._log_error(url, {}, r) - return None + r.raise_for_status() return r.json() def create_project(self, project: ProjectCreate): @@ -181,7 +181,7 @@ def create_project(self, project: ProjectCreate): r = requests.post(url=url, json=payload, timeout=2, headers=headers) if r.status_code != 201: self._log_error(url, payload, r) - return None + r.raise_for_status() return r.json() def get_project(self, project_id): @@ -193,7 +193,7 @@ def get_project(self, project_id): r = requests.get(url=url, timeout=2, headers=headers) if r.status_code != 200: self._log_error(url, {}, r) - return None + r.raise_for_status() return r.json() def add_emission(self, carbon_emission: dict): @@ -235,11 +235,11 @@ def add_emission(self, carbon_emission: dict): r = requests.post(url=url, json=payload, timeout=2, headers=headers) if r.status_code != 201: self._log_error(url, payload, r) - return False + r.raise_for_status() logger.debug(f"ApiClient - Successful upload emission {payload} to {url}") except Exception as e: logger.error(e, exc_info=True) - return False + raise return True def _create_run(self, experiment_id: str): @@ -251,7 +251,7 @@ def _create_run(self, experiment_id: str): logger.error( "ApiClient FATAL The ApiClient._create_run() needs an experiment_id !" ) - return None + raise ValueError("ApiClient._create_run() needs an experiment_id") try: run = RunCreate( timestamp=get_datetime_with_timezone(), @@ -277,7 +277,7 @@ def _create_run(self, experiment_id: str): r = requests.post(url=url, json=payload, timeout=2, headers=headers) if r.status_code != 201: self._log_error(url, payload, r) - return None + r.raise_for_status() self.run_id = r.json()["id"] logger.info( "ApiClient Successfully registered your run on the API.\n\n" @@ -290,8 +290,10 @@ def _create_run(self, experiment_id: str): f"Failed to connect to API, please check the configuration. {e}", exc_info=False, ) + raise except Exception as e: logger.error(e, exc_info=True) + raise def list_experiments_from_project(self, project_id: str): """ @@ -302,7 +304,7 @@ def list_experiments_from_project(self, project_id: str): r = requests.get(url=url, timeout=2, headers=headers) if r.status_code != 200: self._log_error(url, {}, r) - return [] + r.raise_for_status() return r.json() def set_experiment(self, experiment_id: str): @@ -322,7 +324,7 @@ def add_experiment(self, experiment: ExperimentCreate): r = requests.post(url=url, json=payload, timeout=2, headers=headers) if r.status_code != 201: self._log_error(url, payload, r) - return None + r.raise_for_status() return r.json() def get_experiment(self, experiment_id): @@ -334,7 +336,7 @@ def get_experiment(self, experiment_id): r = requests.get(url=url, timeout=2, headers=headers) if r.status_code != 200: self._log_error(url, {}, r) - return None + r.raise_for_status() return r.json() def _log_error(self, url, payload, response): diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 862eba2b4..006cc2448 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -470,14 +470,18 @@ def _init_output_methods(self, *, api_key: str = None): self._output_handlers.append(HTTPOutput(self._emissions_endpoint)) if self._save_to_api: - cc_api__out = CodeCarbonAPIOutput( - endpoint_url=self._api_endpoint, - experiment_id=self._experiment_id, - api_key=api_key, - conf=self._conf, - ) - self.run_id = cc_api__out.run_id - self._output_handlers.append(cc_api__out) + try: + cc_api__out = CodeCarbonAPIOutput( + endpoint_url=self._api_endpoint, + experiment_id=self._experiment_id, + api_key=api_key, + conf=self._conf, + ) + self.run_id = cc_api__out.run_id + self._output_handlers.append(cc_api__out) + except Exception as e: + logger.error(e, exc_info=True) + self.run_id = uuid.uuid4() else: self.run_id = uuid.uuid4() diff --git a/tests/test_api_call.py b/tests/test_api_call.py index ae39f6fd6..85b471e0d 100644 --- a/tests/test_api_call.py +++ b/tests/test_api_call.py @@ -2,6 +2,7 @@ import unittest from uuid import uuid4 +import requests import requests_mock from codecarbon.core.api_client import ApiClient @@ -106,3 +107,527 @@ def test_call_api(self): tracking_mode="Machine", ) assert api.add_emission(dataclasses.asdict(carbon_emission)) + + def test_create_run_error_raises(self): + """Test that _create_run raises HTTPError on server error.""" + with requests_mock.Mocker() as m: + m.post( + "http://test.com/runs", + status_code=500, + ) + with self.assertRaises(requests.exceptions.HTTPError): + ApiClient( + experiment_id="experiment_id", + endpoint_url="http://test.com", + api_key="Toto", + conf=conf, + ) + + def test_create_run_connection_error_raises(self): + """Test that _create_run raises ConnectionError when API is unreachable.""" + with requests_mock.Mocker() as m: + m.post( + "http://test.com/runs", + exc=requests.exceptions.ConnectionError("API unreachable"), + ) + with self.assertRaises(requests.exceptions.ConnectionError): + ApiClient( + experiment_id="experiment_id", + endpoint_url="http://test.com", + api_key="Toto", + conf=conf, + ) + + def test_add_emission_error_raises(self): + """Test that add_emission raises HTTPError on server error.""" + with requests_mock.Mocker() as m: + m.post("http://test.com/runs", json={"id": "run-id"}, status_code=201) + api = ApiClient( + experiment_id="experiment_id", + endpoint_url="http://test.com", + api_key="Toto", + conf=conf, + ) + with requests_mock.Mocker() as m: + m.post("http://test.com/emissions", status_code=500) + carbon_emission = EmissionsData( + timestamp="222", + project_name="", + run_id=uuid4(), + experiment_id="test", + duration=1.5, + emissions=2.0, + emissions_rate=2.0, + cpu_energy=2, + gpu_energy=0, + ram_energy=1, + cpu_power=3.0, + gpu_power=0, + ram_power=0.15, + energy_consumed=3.0, + water_consumed=0.0, + country_name="Groland", + country_iso_code="GRD", + region="EU", + on_cloud="N", + cloud_provider="", + cloud_region="", + os="Linux", + python_version="3.8.0", + codecarbon_version="2.1.3", + gpu_count=4, + gpu_model="NVIDIA", + cpu_count=12, + cpu_model="Intel", + longitude=-7.6174, + latitude=33.5822, + ram_total_size=83948.22, + tracking_mode="Machine", + ) + with self.assertRaises(requests.exceptions.HTTPError): + api.add_emission(dataclasses.asdict(carbon_emission)) + + def test_check_auth_error_raises(self): + """Test that check_auth raises HTTPError on server error.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + with requests_mock.Mocker() as m: + m.get("http://test.com/auth/check", status_code=401) + with self.assertRaises(requests.exceptions.HTTPError): + api.check_auth() + + def test_get_list_organizations_error_raises(self): + """Test that get_list_organizations raises HTTPError on server error.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + with requests_mock.Mocker() as m: + m.get("http://test.com/organizations", status_code=500) + with self.assertRaises(requests.exceptions.HTTPError): + api.get_list_organizations() + + def test_get_organization_error_raises(self): + """Test that get_organization raises HTTPError on server error.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + with requests_mock.Mocker() as m: + m.get("http://test.com/organizations/org-id", status_code=500) + with self.assertRaises(requests.exceptions.HTTPError): + api.get_organization("org-id") + + def test_list_projects_error_raises(self): + """Test that list_projects_from_organization raises HTTPError.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + with requests_mock.Mocker() as m: + m.get("http://test.com/organizations/org-id/projects", status_code=500) + with self.assertRaises(requests.exceptions.HTTPError): + api.list_projects_from_organization("org-id") + + def test_get_project_error_raises(self): + """Test that get_project raises HTTPError on server error.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + with requests_mock.Mocker() as m: + m.get("http://test.com/projects/proj-id", status_code=500) + with self.assertRaises(requests.exceptions.HTTPError): + api.get_project("proj-id") + + def test_list_experiments_error_raises(self): + """Test that list_experiments_from_project raises HTTPError.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + with requests_mock.Mocker() as m: + m.get("http://test.com/projects/proj-id/experiments", status_code=500) + with self.assertRaises(requests.exceptions.HTTPError): + api.list_experiments_from_project("proj-id") + + def test_get_experiment_error_raises(self): + """Test that get_experiment raises HTTPError on server error.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + with requests_mock.Mocker() as m: + m.get("http://test.com/experiments/exp-id", status_code=500) + with self.assertRaises(requests.exceptions.HTTPError): + api.get_experiment("exp-id") + + # ── Success-path tests (cover `return r.json()` after `r.raise_for_status()`) ── + + def test_check_auth_success(self): + """Test check_auth returns JSON on 200.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + with requests_mock.Mocker() as m: + m.get( + "http://test.com/auth/check", + json={"user": "me"}, + status_code=200, + ) + result = api.check_auth() + self.assertEqual(result, {"user": "me"}) + + def test_get_list_organizations_success(self): + """Test get_list_organizations returns JSON on 200.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + with requests_mock.Mocker() as m: + orgs = [{"name": "Org1"}, {"name": "Org2"}] + m.get( + "http://test.com/organizations", + json=orgs, + status_code=200, + ) + result = api.get_list_organizations() + self.assertEqual(result, orgs) + + def test_get_organization_success(self): + """Test get_organization returns JSON on 200.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + with requests_mock.Mocker() as m: + org = {"id": "org-1", "name": "TestOrg"} + m.get( + "http://test.com/organizations/org-1", + json=org, + status_code=200, + ) + result = api.get_organization("org-1") + self.assertEqual(result, org) + + def test_list_projects_from_organization_success(self): + """Test list_projects_from_organization returns JSON on 200.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + with requests_mock.Mocker() as m: + projects = [{"id": "p1", "name": "Proj1"}] + m.get( + "http://test.com/organizations/org-1/projects", + json=projects, + status_code=200, + ) + result = api.list_projects_from_organization("org-1") + self.assertEqual(result, projects) + + def test_get_project_success(self): + """Test get_project returns JSON on 200.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + with requests_mock.Mocker() as m: + project = {"id": "p1", "name": "TestProject"} + m.get( + "http://test.com/projects/p1", + json=project, + status_code=200, + ) + result = api.get_project("p1") + self.assertEqual(result, project) + + def test_list_experiments_from_project_success(self): + """Test list_experiments_from_project returns JSON on 200.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + with requests_mock.Mocker() as m: + experiments = [{"id": "e1", "name": "Exp1"}] + m.get( + "http://test.com/projects/p1/experiments", + json=experiments, + status_code=200, + ) + result = api.list_experiments_from_project("p1") + self.assertEqual(result, experiments) + + def test_get_experiment_success(self): + """Test get_experiment returns JSON on 200.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + with requests_mock.Mocker() as m: + exp = {"id": "e1", "name": "TestExperiment"} + m.get( + "http://test.com/experiments/e1", + json=exp, + status_code=200, + ) + result = api.get_experiment("e1") + self.assertEqual(result, exp) + + # ── Tests for methods with no coverage at all ── + + def test_check_organization_exists_found(self): + """Test check_organization_exists returns org when found.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + with requests_mock.Mocker() as m: + orgs = [{"name": "MyOrg", "id": "org-1"}] + m.get("http://test.com/organizations", json=orgs, status_code=200) + result = api.check_organization_exists("MyOrg") + self.assertEqual(result, {"name": "MyOrg", "id": "org-1"}) + + def test_check_organization_exists_not_found(self): + """Test check_organization_exists returns False when not found.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + with requests_mock.Mocker() as m: + orgs = [{"name": "OtherOrg", "id": "org-2"}] + m.get("http://test.com/organizations", json=orgs, status_code=200) + result = api.check_organization_exists("MyOrg") + self.assertFalse(result) + + def test_create_organization_success(self): + """Test create_organization creates and returns JSON on 201.""" + from codecarbon.core.schemas import OrganizationCreate + + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + org = OrganizationCreate(name="NewOrg", description="A new org") + with requests_mock.Mocker() as m: + # check_organization_exists calls get_list_organizations + m.get("http://test.com/organizations", json=[], status_code=200) + m.post( + "http://test.com/organizations", + json={"id": "org-new", "name": "NewOrg", "description": "A new org"}, + status_code=201, + ) + result = api.create_organization(org) + self.assertEqual(result["name"], "NewOrg") + + def test_create_organization_already_exists(self): + """Test create_organization returns existing org without POST.""" + from codecarbon.core.schemas import OrganizationCreate + + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + org = OrganizationCreate(name="ExistingOrg", description="Already there") + with requests_mock.Mocker() as m: + m.get( + "http://test.com/organizations", + json=[{"name": "ExistingOrg", "id": "org-existing"}], + status_code=200, + ) + result = api.create_organization(org) + self.assertEqual(result["id"], "org-existing") + + def test_create_organization_error_raises(self): + """Test create_organization raises HTTPError on server error.""" + from codecarbon.core.schemas import OrganizationCreate + + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + org = OrganizationCreate(name="NewOrg", description="A new org") + with requests_mock.Mocker() as m: + m.get("http://test.com/organizations", json=[], status_code=200) + m.post("http://test.com/organizations", status_code=500) + with self.assertRaises(requests.exceptions.HTTPError): + api.create_organization(org) + + def test_update_organization_success(self): + """Test update_organization returns JSON on 200.""" + from codecarbon.core.schemas import OrganizationCreate + + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + org = OrganizationCreate(name="Updated", description="Updated desc") + org.id = "org-1" + with requests_mock.Mocker() as m: + m.patch( + "http://test.com/organizations/org-1", + json={"id": "org-1", "name": "Updated"}, + status_code=200, + ) + result = api.update_organization(org) + self.assertEqual(result["name"], "Updated") + + def test_update_organization_error_raises(self): + """Test update_organization raises HTTPError on server error.""" + from codecarbon.core.schemas import OrganizationCreate + + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + org = OrganizationCreate(name="Updated", description="desc") + org.id = "org-1" + with requests_mock.Mocker() as m: + m.patch("http://test.com/organizations/org-1", status_code=500) + with self.assertRaises(requests.exceptions.HTTPError): + api.update_organization(org) + + def test_create_project_success(self): + """Test create_project returns JSON on 201.""" + from codecarbon.core.schemas import ProjectCreate + + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + project = ProjectCreate( + name="NewProject", description="desc", organization_id="org-1" + ) + with requests_mock.Mocker() as m: + m.post( + "http://test.com/projects", + json={"id": "p1", "name": "NewProject"}, + status_code=201, + ) + result = api.create_project(project) + self.assertEqual(result["name"], "NewProject") + + def test_create_project_error_raises(self): + """Test create_project raises HTTPError on server error.""" + from codecarbon.core.schemas import ProjectCreate + + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + project = ProjectCreate( + name="NewProject", description="desc", organization_id="org-1" + ) + with requests_mock.Mocker() as m: + m.post("http://test.com/projects", status_code=500) + with self.assertRaises(requests.exceptions.HTTPError): + api.create_project(project) + + def test_add_experiment_success(self): + """Test add_experiment returns JSON on 201.""" + from codecarbon.core.schemas import ExperimentCreate + + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + experiment = ExperimentCreate( + timestamp="2021-04-04T08:43:00+02:00", + name="TestExp", + description="desc", + on_cloud=False, + project_id="00000000-0000-0000-0000-000000000001", + ) + with requests_mock.Mocker() as m: + m.post( + "http://test.com/experiments", + json={"id": "e1", "name": "TestExp"}, + status_code=201, + ) + result = api.add_experiment(experiment) + self.assertEqual(result["name"], "TestExp") + + def test_add_experiment_error_raises(self): + """Test add_experiment raises HTTPError on server error.""" + from codecarbon.core.schemas import ExperimentCreate + + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=None, + create_run_automatically=False, + ) + experiment = ExperimentCreate( + timestamp="2021-04-04T08:43:00+02:00", + name="TestExp", + description="desc", + on_cloud=False, + project_id="00000000-0000-0000-0000-000000000001", + ) + with requests_mock.Mocker() as m: + m.post("http://test.com/experiments", status_code=500) + with self.assertRaises(requests.exceptions.HTTPError): + api.add_experiment(experiment) + + def test_create_run_no_experiment_id_raises(self): + """Test _create_run raises ValueError when experiment_id is None.""" + api = ApiClient( + endpoint_url="http://test.com", + api_key="Toto", + conf=conf, + create_run_automatically=False, + ) + api.experiment_id = None + with self.assertRaises(ValueError): + api._create_run("some-experiment-id") diff --git a/tests/test_emissions_tracker.py b/tests/test_emissions_tracker.py index ab4a0a275..0524d0674 100644 --- a/tests/test_emissions_tracker.py +++ b/tests/test_emissions_tracker.py @@ -798,3 +798,72 @@ def test_cumulative_emissions_with_varying_intensity( # Verification: If it wasn't cumulative, it would be 3.0 kWh * 300 g/kWh = 0.9 kg self.assertLess(data3.emissions, 0.8) + + @responses.activate + @mock.patch("codecarbon.emissions_tracker.CodeCarbonAPIOutput") + def test_save_to_api_fallback_on_error( + self, + mock_api_output_cls, + mock_cli_setup, + mock_log_values, + mocked_get_gpu_details, + mocked_env_cloud_details, + mocked_is_gpu_details_available, + mocked_is_nvidia_system, + ): + """Test that when save_to_api=True and the API is unreachable, + the tracker falls back to a local UUID run_id without crashing.""" + import uuid + + responses.add( + responses.GET, + "https://get.geojs.io/v1/ip/geo.json", + json=GEO_METADATA_CANADA, + status=200, + ) + + # Simulate API connection failure + mock_api_output_cls.side_effect = Exception("API unreachable") + + tracker = EmissionsTracker( + save_to_api=True, + save_to_file=False, + measure_power_secs=1, + ) + + # run_id should be a uuid4 fallback, not None + self.assertIsNotNone(tracker.run_id) + self.assertIsInstance(tracker.run_id, uuid.UUID) + + @responses.activate + @mock.patch("codecarbon.emissions_tracker.CodeCarbonAPIOutput") + def test_save_to_api_success( + self, + mock_api_output_cls, + mock_cli_setup, + mock_log_values, + mocked_get_gpu_details, + mocked_env_cloud_details, + mocked_is_gpu_details_available, + mocked_is_nvidia_system, + ): + """Test that when save_to_api=True and the API is reachable, + the tracker uses the run_id from the API output.""" + responses.add( + responses.GET, + "https://get.geojs.io/v1/ip/geo.json", + json=GEO_METADATA_CANADA, + status=200, + ) + + mock_api_instance = mock.MagicMock() + mock_api_instance.run_id = "api-run-id-123" + mock_api_output_cls.return_value = mock_api_instance + + tracker = EmissionsTracker( + save_to_api=True, + save_to_file=False, + measure_power_secs=1, + ) + + self.assertEqual(tracker.run_id, "api-run-id-123")