From af78bb6a457eb8096172684da7536e18f63f1552 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Mon, 27 Jul 2026 16:38:27 +0530 Subject: [PATCH 1/7] fix: raise HTTP errors from ApiClient instead of returning None Call raise_for_status after logging API failures so callers get meaningful errors, catch failures in EmissionsTracker so tracking continues offline, and update tests that assumed silent None returns. Fixes #820 Co-authored-by: Cursor --- codecarbon/cli/main.py | 3 -- codecarbon/core/api_client.py | 20 +++---------- codecarbon/emissions_tracker.py | 25 +++++++++++------ tests/test_api_call.py | 50 +++++++++++++++++++++++---------- tests/test_emissions_tracker.py | 30 ++++++++++++++++++++ 5 files changed, 86 insertions(+), 42 deletions(-) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index c10b32338..df8a66c89 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -230,9 +230,6 @@ def config(): description=org_description, ) organization = api.create_organization(organization=organization_create) - if organization is None: - print("Error creating organization") - return print(f"Created organization : {organization}") else: organization = [orga for orga in organizations if orga["name"] == org][0] diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index 58f4932cb..eed9b0ef9 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -86,7 +86,6 @@ 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 return r.json() def get_list_organizations(self): @@ -98,7 +97,6 @@ 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 return r.json() def check_organization_exists(self, organization_name: str): @@ -106,8 +104,6 @@ def check_organization_exists(self, organization_name: str): Check if an organization exists """ organizations = self.get_list_organizations() - if organizations is None: - return False for organization in organizations: if organization["name"] == organization_name: return organization @@ -129,7 +125,6 @@ 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 return r.json() def get_organization(self, organization_id): @@ -141,7 +136,6 @@ 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 return r.json() def update_organization(self, organization: OrganizationCreate): @@ -154,7 +148,6 @@ 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 return r.json() def list_projects_from_organization(self, organization_id): @@ -166,7 +159,6 @@ 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 return r.json() def create_project(self, project: ProjectCreate): @@ -179,7 +171,6 @@ 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 return r.json() def get_project(self, project_id): @@ -191,7 +182,6 @@ 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 return r.json() def add_emission(self, carbon_emission: dict): @@ -237,11 +227,10 @@ 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 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): @@ -279,7 +268,6 @@ 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 self.run_id = r.json()["id"] logger.info( "ApiClient Successfully registered your run on the API.\n\n" @@ -292,8 +280,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): """ @@ -304,7 +294,6 @@ 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 [] return r.json() def set_experiment(self, experiment_id: str): @@ -324,7 +313,6 @@ 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 return r.json() def get_experiment(self, experiment_id): @@ -336,7 +324,6 @@ 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 return r.json() def _log_error(self, url, payload, response): @@ -349,6 +336,7 @@ def _log_error(self, url, payload, response): logger.error( f"ApiClient API return http code {response.status_code} and answer : {response.text}" ) + response.raise_for_status() def close_experiment(self): """ diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 96ed00c91..ffc61e030 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -639,14 +639,23 @@ def _init_output_methods(self, *, api_key: str = None): self._output_handlers.append(HTTPOutput(self._emissions_endpoint)) if OutputMethod.API in methods: - 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( + "Failed to initialize Code Carbon API output; " + "continuing without API reporting. %s", + 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 39822ece7..29be5ce82 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 @@ -137,7 +138,7 @@ def test_call_api(self): assert payload["ram_utilization_percent"] == 56.5 assert payload["wue"] == 0.8 - def test_check_auth_returns_none_on_error(self): + def test_check_auth_raises_on_error(self): with requests_mock.Mocker() as m: m.get("http://test.com/auth/check", text="bad", status_code=401) api = ApiClient( @@ -146,9 +147,10 @@ def test_check_auth_returns_none_on_error(self): create_run_automatically=False, ) - self.assertIsNone(api.check_auth()) + with self.assertRaises(requests.exceptions.HTTPError): + api.check_auth() - def test_check_organization_exists_returns_false_when_list_fails(self): + def test_check_organization_exists_raises_when_list_fails(self): with requests_mock.Mocker() as m: m.get("http://test.com/organizations", text="bad", status_code=500) api = ApiClient( @@ -156,7 +158,8 @@ def test_check_organization_exists_returns_false_when_list_fails(self): create_run_automatically=False, ) - self.assertFalse(api.check_organization_exists("missing")) + with self.assertRaises(requests.exceptions.HTTPError): + api.check_organization_exists("missing") def test_create_organization_skips_when_name_exists(self): organization = OrganizationCreate(name="existing", description="desc") @@ -225,7 +228,7 @@ def test_add_emission_skips_short_duration(self): ) ) - def test_add_emission_returns_false_on_unsuccessful_post(self): + def test_add_emission_raises_on_unsuccessful_post(self): with requests_mock.Mocker() as m: m.post("http://test.com/emissions", text="bad", status_code=500) api = ApiClient( @@ -236,7 +239,7 @@ def test_add_emission_returns_false_on_unsuccessful_post(self): ) api.run_id = "run-1" - self.assertFalse( + with self.assertRaises(requests.exceptions.HTTPError): api.add_emission( { "duration": 2, @@ -251,9 +254,8 @@ def test_add_emission_returns_false_on_unsuccessful_post(self): "energy_consumed": 0.2, } ) - ) - def test_create_run_returns_none_on_unsuccessful_status(self): + def test_create_run_raises_on_unsuccessful_status(self): with requests_mock.Mocker() as m: m.post("http://test.com/runs", text="bad", status_code=400) api = ApiClient( @@ -264,10 +266,11 @@ def test_create_run_returns_none_on_unsuccessful_status(self): create_run_automatically=False, ) - self.assertIsNone(api._create_run("experiment_id")) + with self.assertRaises(requests.exceptions.HTTPError): + api._create_run("experiment_id") self.assertIsNone(api.run_id) - def test_list_experiments_from_project_returns_empty_list_on_error(self): + def test_list_experiments_from_project_raises_on_error(self): with requests_mock.Mocker() as m: m.get( "http://test.com/projects/proj-1/experiments", @@ -279,7 +282,8 @@ def test_list_experiments_from_project_returns_empty_list_on_error(self): create_run_automatically=False, ) - self.assertEqual(api.list_experiments_from_project("proj-1"), []) + with self.assertRaises(requests.exceptions.HTTPError): + api.list_experiments_from_project("proj-1") def test_set_experiment_updates_value(self): api = ApiClient(endpoint_url="http://test.com", create_run_automatically=False) @@ -288,7 +292,7 @@ def test_set_experiment_updates_value(self): self.assertEqual(api.experiment_id, "exp-2") - def test_add_experiment_returns_none_on_error(self): + def test_add_experiment_raises_on_error(self): experiment = ExperimentCreate( timestamp="2024-01-01T00:00:00+00:00", name="exp", @@ -303,9 +307,10 @@ def test_add_experiment_returns_none_on_error(self): create_run_automatically=False, ) - self.assertIsNone(api.add_experiment(experiment)) + with self.assertRaises(requests.exceptions.HTTPError): + api.add_experiment(experiment) - def test_get_experiment_returns_none_on_error(self): + def test_get_experiment_raises_on_error(self): with requests_mock.Mocker() as m: m.get("http://test.com/experiments/exp-1", text="bad", status_code=404) api = ApiClient( @@ -313,4 +318,19 @@ def test_get_experiment_returns_none_on_error(self): create_run_automatically=False, ) - self.assertIsNone(api.get_experiment("exp-1")) + with self.assertRaises(requests.exceptions.HTTPError): + api.get_experiment("exp-1") + + def test_create_run_raises_on_connection_error(self): + 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, + ) diff --git a/tests/test_emissions_tracker.py b/tests/test_emissions_tracker.py index 8ab12e5d8..40c3f20ca 100644 --- a/tests/test_emissions_tracker.py +++ b/tests/test_emissions_tracker.py @@ -425,6 +425,36 @@ def test_output_methods_overrides_save_to_flags( ) ) + def test_api_output_init_failure_does_not_break_tracker( + self, + mock_cli_setup, + mock_log_values, + mocked_get_gpu_details, + mocked_env_cloud_details, + mocked_get_gpu_utilization_list, + mocked_is_gpu_details_available, + mocked_is_nvidia_system, + ): + with mock.patch( + "codecarbon.output_methods.http.CodeCarbonAPIOutput", + side_effect=requests.exceptions.HTTPError("API unavailable"), + ): + tracker = EmissionsTracker( + output_dir=self.temp_path, + output_handlers=[], + output_methods=[OutputMethod.CSV, OutputMethod.API], + api_key="test-key", + experiment_id="exp-1", + ) + + self.assertIsNotNone(tracker.run_id) + self.assertFalse( + any( + isinstance(handler, CodeCarbonAPIOutput) + for handler in tracker._output_handlers + ) + ) + def test_output_methods_parsed_from_config_string( self, mock_cli_setup, From 7ba5ad88b3ee5e478a67832516558cf315148a04 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Tue, 28 Jul 2026 15:41:56 +0530 Subject: [PATCH 2/7] fix: raise on unexpected API status codes _raise_api_error now raises for any unexpected status (including 2xx/3xx), CLI commands print friendly errors, and tests cover the unexpected-2xx path. --- codecarbon/cli/main.py | 27 ++++++++++++++------ codecarbon/core/api_client.py | 36 ++++++++++++++++----------- tests/cli/test_cli_main.py | 41 ++++++++++++++++++++++++++++++ tests/test_api_call.py | 44 +++++++++++++++++++++++++++++++++ tests/test_emissions_tracker.py | 3 ++- 5 files changed, 129 insertions(+), 22 deletions(-) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index df8a66c89..7eddea377 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -116,7 +116,11 @@ def api_get(): api_endpoint = get_api_endpoint() api = ApiClient(endpoint_url=api_endpoint) api.set_access_token(get_access_token()) - organizations = api.get_list_organizations() + try: + organizations = api.get_list_organizations() + except Exception as e: + print(f"[yellow]API request failed[/yellow]. (error: {e})") + raise typer.Exit(1) print(organizations) @@ -130,7 +134,11 @@ def login(): api = ApiClient(endpoint_url=api_endpoint) access_token = get_access_token() api.set_access_token(access_token) - api.check_auth() + try: + api.check_auth() + except Exception as e: + print(f"[yellow]Authentication check failed[/yellow]. (error: {e})") + raise typer.Exit(1) def get_api_key(project_id: str): @@ -212,7 +220,14 @@ def config(): overwrite_local_config("api_endpoint", api_endpoint, path=file_path) api = ApiClient(endpoint_url=api_endpoint) api.set_access_token(get_access_token()) - organizations = api.get_list_organizations() + try: + organizations = api.get_list_organizations() + except Exception as e: + print( + f"[yellow]Could not list organizations from API[/yellow]. " + f"Please check your login and API endpoint. (error: {e})" + ) + return org = questionary_prompt( "Pick existing organization from list or Create new organization ?", [org["name"] for org in organizations] + ["Create New Organization"], @@ -237,7 +252,7 @@ def config(): overwrite_local_config("organization_id", org_id, path=file_path) projects = api.list_projects_from_organization(org_id) - project_names = [project["name"] for project in projects] if projects else [] + project_names = [project["name"] for project in projects] project = questionary_prompt( "Pick existing project from list or Create new project ?", project_names + ["Create New Project"], @@ -261,9 +276,7 @@ def config(): overwrite_local_config("project_id", project_id, path=file_path) experiments = api.list_experiments_from_project(project_id) - experiments_names = ( - [experiment["name"] for experiment in experiments] if experiments else [] - ) + experiments_names = [experiment["name"] for experiment in experiments] experiment = questionary_prompt( "Pick existing experiment from list or Create new experiment ?", diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index eed9b0ef9..3d031cfc5 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -85,7 +85,7 @@ def check_auth(self): headers = self._get_headers() r = requests.get(url=url, timeout=2, headers=headers) if r.status_code != 200: - self._log_error(url, {}, r) + self._raise_api_error(url, {}, r) return r.json() def get_list_organizations(self): @@ -96,7 +96,7 @@ def get_list_organizations(self): headers = self._get_headers() r = requests.get(url=url, timeout=2, headers=headers) if r.status_code != 200: - self._log_error(url, {}, r) + self._raise_api_error(url, {}, r) return r.json() def check_organization_exists(self, organization_name: str): @@ -124,7 +124,7 @@ def create_organization(self, organization: OrganizationCreate): headers = self._get_headers() r = requests.post(url=url, json=payload, timeout=2, headers=headers) if r.status_code != 201: - self._log_error(url, payload, r) + self._raise_api_error(url, payload, r) return r.json() def get_organization(self, organization_id): @@ -135,7 +135,7 @@ def get_organization(self, organization_id): url = self.url + "/organizations/" + organization_id r = requests.get(url=url, timeout=2, headers=headers) if r.status_code != 200: - self._log_error(url, {}, r) + self._raise_api_error(url, {}, r) return r.json() def update_organization(self, organization: OrganizationCreate): @@ -147,7 +147,7 @@ def update_organization(self, organization: OrganizationCreate): url = self.url + "/organizations/" + organization.id r = requests.patch(url=url, json=payload, timeout=2, headers=headers) if r.status_code != 200: - self._log_error(url, payload, r) + self._raise_api_error(url, payload, r) return r.json() def list_projects_from_organization(self, organization_id): @@ -158,7 +158,7 @@ def list_projects_from_organization(self, organization_id): headers = self._get_headers() r = requests.get(url=url, timeout=2, headers=headers) if r.status_code != 200: - self._log_error(url, {}, r) + self._raise_api_error(url, {}, r) return r.json() def create_project(self, project: ProjectCreate): @@ -170,7 +170,7 @@ def create_project(self, project: ProjectCreate): headers = self._get_headers() r = requests.post(url=url, json=payload, timeout=2, headers=headers) if r.status_code != 201: - self._log_error(url, payload, r) + self._raise_api_error(url, payload, r) return r.json() def get_project(self, project_id): @@ -181,7 +181,7 @@ def get_project(self, project_id): headers = self._get_headers() r = requests.get(url=url, timeout=2, headers=headers) if r.status_code != 200: - self._log_error(url, {}, r) + self._raise_api_error(url, {}, r) return r.json() def add_emission(self, carbon_emission: dict): @@ -226,8 +226,10 @@ def add_emission(self, carbon_emission: dict): headers = self._get_headers() r = requests.post(url=url, json=payload, timeout=2, headers=headers) if r.status_code != 201: - self._log_error(url, payload, r) + self._raise_api_error(url, payload, r) logger.debug(f"ApiClient - Successful upload emission {payload} to {url}") + except requests.exceptions.HTTPError: + raise except Exception as e: logger.error(e, exc_info=True) raise @@ -267,7 +269,7 @@ def _create_run(self, experiment_id: str): headers = self._get_headers() r = requests.post(url=url, json=payload, timeout=2, headers=headers) if r.status_code != 201: - self._log_error(url, payload, r) + self._raise_api_error(url, payload, r) self.run_id = r.json()["id"] logger.info( "ApiClient Successfully registered your run on the API.\n\n" @@ -281,6 +283,8 @@ def _create_run(self, experiment_id: str): exc_info=False, ) raise + except requests.exceptions.HTTPError: + raise except Exception as e: logger.error(e, exc_info=True) raise @@ -293,7 +297,7 @@ def list_experiments_from_project(self, project_id: str): headers = self._get_headers() r = requests.get(url=url, timeout=2, headers=headers) if r.status_code != 200: - self._log_error(url, {}, r) + self._raise_api_error(url, {}, r) return r.json() def set_experiment(self, experiment_id: str): @@ -312,7 +316,7 @@ def add_experiment(self, experiment: ExperimentCreate): headers = self._get_headers() r = requests.post(url=url, json=payload, timeout=2, headers=headers) if r.status_code != 201: - self._log_error(url, payload, r) + self._raise_api_error(url, payload, r) return r.json() def get_experiment(self, experiment_id): @@ -323,10 +327,10 @@ def get_experiment(self, experiment_id): headers = self._get_headers() r = requests.get(url=url, timeout=2, headers=headers) if r.status_code != 200: - self._log_error(url, {}, r) + self._raise_api_error(url, {}, r) return r.json() - def _log_error(self, url, payload, response): + def _raise_api_error(self, url, payload, response): if len(payload) > 0: logger.error( f"ApiClient Error when calling the API on {url} with : {json.dumps(payload)}" @@ -337,6 +341,10 @@ def _log_error(self, url, payload, response): f"ApiClient API return http code {response.status_code} and answer : {response.text}" ) response.raise_for_status() + # 2xx/3xx that still isn't what the caller expected + raise requests.exceptions.HTTPError( + f"Unexpected status {response.status_code} from {url}", response=response + ) def close_experiment(self): """ diff --git a/tests/cli/test_cli_main.py b/tests/cli/test_cli_main.py index 84f42493d..b0c3ec593 100644 --- a/tests/cli/test_cli_main.py +++ b/tests/cli/test_cli_main.py @@ -3,6 +3,7 @@ from types import SimpleNamespace import pytest +import requests import typer from typer.testing import CliRunner @@ -43,6 +44,21 @@ def test_api_get_calls_api_and_prints(monkeypatch): assert "fake-org" in result.output +def test_api_get_prints_friendly_error_on_api_failure(monkeypatch): + class FailingApiClient(FakeApiClient): + def get_list_organizations(self): + raise requests.exceptions.HTTPError("401 Unauthorized") + + runner = CliRunner() + monkeypatch.setattr("codecarbon.core.api_client.ApiClient", FailingApiClient) + monkeypatch.setattr("codecarbon.cli.auth.get_access_token", fake_get_access_token) + + result = runner.invoke(cli_main.codecarbon, ["test-api"]) + assert result.exit_code == 1 + assert "API request failed" in result.output + assert "401 Unauthorized" in result.output + + def test_api_get_uses_get_api_endpoint(monkeypatch): call_info = {} @@ -185,6 +201,31 @@ def check_auth(self): assert calls["endpoint_url"] == "https://custom-login.codecarbon.io" +def test_login_prints_friendly_error_on_auth_failure(monkeypatch): + class FailingApiClient: + def __init__(self, endpoint_url=None): + pass + + def set_access_token(self, token): + pass + + def check_auth(self): + raise requests.exceptions.HTTPError("403 Forbidden") + + monkeypatch.setattr("codecarbon.core.api_client.ApiClient", FailingApiClient) + monkeypatch.setattr("codecarbon.cli.auth.authorize", lambda: None) + monkeypatch.setattr( + cli_main, "get_api_endpoint", lambda: "https://api.codecarbon.io" + ) + monkeypatch.setattr("codecarbon.cli.auth.get_access_token", lambda: "bad-token") + + runner = CliRunner() + result = runner.invoke(cli_main.codecarbon, ["login"]) + assert result.exit_code == 1 + assert "Authentication check failed" in result.output + assert "403 Forbidden" in result.output + + def test_get_api_key_uses_bearer_token(monkeypatch): captured = {} diff --git a/tests/test_api_call.py b/tests/test_api_call.py index 29be5ce82..a5178f7b3 100644 --- a/tests/test_api_call.py +++ b/tests/test_api_call.py @@ -270,6 +270,50 @@ def test_create_run_raises_on_unsuccessful_status(self): api._create_run("experiment_id") self.assertIsNone(api.run_id) + def test_create_run_raises_on_unexpected_2xx_status(self): + with requests_mock.Mocker() as m: + m.post("http://test.com/runs", json={}, status_code=200) + api = ApiClient( + endpoint_url="http://test.com", + experiment_id="experiment_id", + api_key="Toto", + conf=conf, + create_run_automatically=False, + ) + + with self.assertRaises(requests.exceptions.HTTPError) as ctx: + api._create_run("experiment_id") + self.assertIn("Unexpected status 200", str(ctx.exception)) + self.assertIsNone(api.run_id) + + def test_add_emission_raises_on_unexpected_2xx_status(self): + with requests_mock.Mocker() as m: + m.post("http://test.com/emissions", json={}, status_code=200) + api = ApiClient( + endpoint_url="http://test.com", + experiment_id="exp-1", + conf=conf, + create_run_automatically=False, + ) + api.run_id = "run-1" + + with self.assertRaises(requests.exceptions.HTTPError) as ctx: + api.add_emission( + { + "duration": 2, + "emissions": 1.0, + "emissions_rate": 1.0, + "cpu_power": 1.0, + "gpu_power": 0.0, + "ram_power": 0.5, + "cpu_energy": 0.1, + "gpu_energy": 0.0, + "ram_energy": 0.1, + "energy_consumed": 0.2, + } + ) + self.assertIn("Unexpected status 200", str(ctx.exception)) + def test_list_experiments_from_project_raises_on_error(self): with requests_mock.Mocker() as m: m.get( diff --git a/tests/test_emissions_tracker.py b/tests/test_emissions_tracker.py index 40c3f20ca..584f00321 100644 --- a/tests/test_emissions_tracker.py +++ b/tests/test_emissions_tracker.py @@ -438,7 +438,7 @@ def test_api_output_init_failure_does_not_break_tracker( with mock.patch( "codecarbon.output_methods.http.CodeCarbonAPIOutput", side_effect=requests.exceptions.HTTPError("API unavailable"), - ): + ) as mock_api_output: tracker = EmissionsTracker( output_dir=self.temp_path, output_handlers=[], @@ -447,6 +447,7 @@ def test_api_output_init_failure_does_not_break_tracker( experiment_id="exp-1", ) + mock_api_output.assert_called_once() self.assertIsNotNone(tracker.run_id) self.assertFalse( any( From 28b3e79be1b769869c6fcdf8a26c3eeca18939fa Mon Sep 17 00:00:00 2001 From: Sankalp Thakur <31366524+sankalpsthakur@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:43:36 +0530 Subject: [PATCH 3/7] chore: apply review fix for API output initialization --- .github/workflows/resolve-review-1277.yml | 82 +++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .github/workflows/resolve-review-1277.yml diff --git a/.github/workflows/resolve-review-1277.yml b/.github/workflows/resolve-review-1277.yml new file mode 100644 index 000000000..2ae101e13 --- /dev/null +++ b/.github/workflows/resolve-review-1277.yml @@ -0,0 +1,82 @@ +name: Resolve review 1277 + +on: + push: + branches: + - fix/api-client-raise-for-status-820 + +permissions: + contents: write + +jobs: + apply-review-fix: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: fix/api-client-raise-for-status-820 + + - name: Remove unnecessary initialization guard and synthetic test + run: | + python - <<'PY' + from pathlib import Path + + tracker_path = Path("codecarbon/emissions_tracker.py") + tracker = tracker_path.read_text() + old = ''' if OutputMethod.API in methods: + 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( + "Failed to initialize Code Carbon API output; " + "continuing without API reporting. %s", + e, + exc_info=True, + ) + self.run_id = uuid.uuid4() + else: + self.run_id = uuid.uuid4() + ''' + new = ''' if OutputMethod.API in methods: + 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) + else: + self.run_id = uuid.uuid4() + ''' + if old not in tracker: + raise SystemExit("Expected emissions_tracker block not found") + tracker_path.write_text(tracker.replace(old, new, 1)) + + tests_path = Path("tests/test_emissions_tracker.py") + tests = tests_path.read_text() + start = tests.index(" def test_api_output_init_failure_does_not_break_tracker(\n") + end = tests.index(" def test_output_methods_parsed_from_config_string(\n", start) + tests_path.write_text(tests[:start] + tests[end:]) + PY + + - name: Verify Python syntax + run: python -m compileall -q codecarbon/emissions_tracker.py tests/test_emissions_tracker.py + + - name: Amend branch without workflow file + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + rm .github/workflows/resolve-review-1277.yml + git add codecarbon/emissions_tracker.py tests/test_emissions_tracker.py .github/workflows/resolve-review-1277.yml + git commit --amend -m "fix: remove unnecessary API output init guard" + git push --force-with-lease origin HEAD:fix/api-client-raise-for-status-820 From 5c9af5e72953aad891530b7e77ab332c1d544337 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur <31366524+sankalpsthakur@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:50:16 +0530 Subject: [PATCH 4/7] fix: remove unnecessary API output init guard --- .github/workflows/resolve-review-1277.yml | 82 ----------------------- codecarbon/emissions_tracker.py | 25 +++---- tests/test_emissions_tracker.py | 31 --------- 3 files changed, 8 insertions(+), 130 deletions(-) delete mode 100644 .github/workflows/resolve-review-1277.yml diff --git a/.github/workflows/resolve-review-1277.yml b/.github/workflows/resolve-review-1277.yml deleted file mode 100644 index 2ae101e13..000000000 --- a/.github/workflows/resolve-review-1277.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: Resolve review 1277 - -on: - push: - branches: - - fix/api-client-raise-for-status-820 - -permissions: - contents: write - -jobs: - apply-review-fix: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: fix/api-client-raise-for-status-820 - - - name: Remove unnecessary initialization guard and synthetic test - run: | - python - <<'PY' - from pathlib import Path - - tracker_path = Path("codecarbon/emissions_tracker.py") - tracker = tracker_path.read_text() - old = ''' if OutputMethod.API in methods: - 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( - "Failed to initialize Code Carbon API output; " - "continuing without API reporting. %s", - e, - exc_info=True, - ) - self.run_id = uuid.uuid4() - else: - self.run_id = uuid.uuid4() - ''' - new = ''' if OutputMethod.API in methods: - 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) - else: - self.run_id = uuid.uuid4() - ''' - if old not in tracker: - raise SystemExit("Expected emissions_tracker block not found") - tracker_path.write_text(tracker.replace(old, new, 1)) - - tests_path = Path("tests/test_emissions_tracker.py") - tests = tests_path.read_text() - start = tests.index(" def test_api_output_init_failure_does_not_break_tracker(\n") - end = tests.index(" def test_output_methods_parsed_from_config_string(\n", start) - tests_path.write_text(tests[:start] + tests[end:]) - PY - - - name: Verify Python syntax - run: python -m compileall -q codecarbon/emissions_tracker.py tests/test_emissions_tracker.py - - - name: Amend branch without workflow file - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - rm .github/workflows/resolve-review-1277.yml - git add codecarbon/emissions_tracker.py tests/test_emissions_tracker.py .github/workflows/resolve-review-1277.yml - git commit --amend -m "fix: remove unnecessary API output init guard" - git push --force-with-lease origin HEAD:fix/api-client-raise-for-status-820 diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index ffc61e030..96ed00c91 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -639,23 +639,14 @@ def _init_output_methods(self, *, api_key: str = None): self._output_handlers.append(HTTPOutput(self._emissions_endpoint)) if OutputMethod.API in methods: - 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( - "Failed to initialize Code Carbon API output; " - "continuing without API reporting. %s", - e, - exc_info=True, - ) - self.run_id = uuid.uuid4() + 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) else: self.run_id = uuid.uuid4() diff --git a/tests/test_emissions_tracker.py b/tests/test_emissions_tracker.py index 584f00321..8ab12e5d8 100644 --- a/tests/test_emissions_tracker.py +++ b/tests/test_emissions_tracker.py @@ -425,37 +425,6 @@ def test_output_methods_overrides_save_to_flags( ) ) - def test_api_output_init_failure_does_not_break_tracker( - self, - mock_cli_setup, - mock_log_values, - mocked_get_gpu_details, - mocked_env_cloud_details, - mocked_get_gpu_utilization_list, - mocked_is_gpu_details_available, - mocked_is_nvidia_system, - ): - with mock.patch( - "codecarbon.output_methods.http.CodeCarbonAPIOutput", - side_effect=requests.exceptions.HTTPError("API unavailable"), - ) as mock_api_output: - tracker = EmissionsTracker( - output_dir=self.temp_path, - output_handlers=[], - output_methods=[OutputMethod.CSV, OutputMethod.API], - api_key="test-key", - experiment_id="exp-1", - ) - - mock_api_output.assert_called_once() - self.assertIsNotNone(tracker.run_id) - self.assertFalse( - any( - isinstance(handler, CodeCarbonAPIOutput) - for handler in tracker._output_handlers - ) - ) - def test_output_methods_parsed_from_config_string( self, mock_cli_setup, From 8fc4a1c675d6525c8cc773a779de09d38f2796e9 Mon Sep 17 00:00:00 2001 From: benoit-cty Date: Mon, 3 Aug 2026 21:39:34 +0200 Subject: [PATCH 5/7] fix: guard every CLI call to the API against the errors now raised Following the review on #1277: - add a `_api_call` helper in the CLI that turns the exceptions ApiClient now raises into a readable message and `typer.Exit(1)` instead of a traceback, and use it for every API call of the `config` wizard (create_organization, list_projects_from_organization, create_project, list_experiments_from_project, add_experiment, get_api_key), which were left unguarded. - `get_api_key` calls `raise_for_status()` so a failed token request reports the HTTP error instead of a KeyError on the JSON body. - document why `except HTTPError: raise` is needed in `add_emission` and `_create_run` (avoid logging the error a second time). Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/cli/main.py | 69 +++++++++++++++++++++++------------ codecarbon/core/api_client.py | 5 +++ tests/cli/test_cli_main.py | 40 ++++++++++++++++++++ 3 files changed, 90 insertions(+), 24 deletions(-) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 7eddea377..93f627e5b 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -40,6 +40,20 @@ def main(): raise sys.exit(1) +def _api_call(action: str, func, *args, **kwargs): + """ + Run a call to the Code Carbon API, turning the errors it now raises into a + readable message and a clean exit instead of a traceback. + + :action: what was being attempted, used as the first part of the message. + """ + try: + return func(*args, **kwargs) + except Exception as e: + print(f"[yellow]{action}[/yellow]. (error: {e})") + raise typer.Exit(1) + + def _version_callback(value: bool) -> None: if value: print(f"{__app_name__} v{__version__}") @@ -116,11 +130,7 @@ def api_get(): api_endpoint = get_api_endpoint() api = ApiClient(endpoint_url=api_endpoint) api.set_access_token(get_access_token()) - try: - organizations = api.get_list_organizations() - except Exception as e: - print(f"[yellow]API request failed[/yellow]. (error: {e})") - raise typer.Exit(1) + organizations = _api_call("API request failed", api.get_list_organizations) print(organizations) @@ -134,11 +144,7 @@ def login(): api = ApiClient(endpoint_url=api_endpoint) access_token = get_access_token() api.set_access_token(access_token) - try: - api.check_auth() - except Exception as e: - print(f"[yellow]Authentication check failed[/yellow]. (error: {e})") - raise typer.Exit(1) + _api_call("Authentication check failed", api.check_auth) def get_api_key(project_id: str): @@ -157,6 +163,7 @@ def get_api_key(project_id: str): }, headers={"Authorization": f"Bearer {get_access_token()}"}, ) + req.raise_for_status() api_key = req.json()["token"] return api_key @@ -220,14 +227,10 @@ def config(): overwrite_local_config("api_endpoint", api_endpoint, path=file_path) api = ApiClient(endpoint_url=api_endpoint) api.set_access_token(get_access_token()) - try: - organizations = api.get_list_organizations() - except Exception as e: - print( - f"[yellow]Could not list organizations from API[/yellow]. " - f"Please check your login and API endpoint. (error: {e})" - ) - return + organizations = _api_call( + "Could not list organizations from API. Please check your login and API endpoint", + api.get_list_organizations, + ) org = questionary_prompt( "Pick existing organization from list or Create new organization ?", [org["name"] for org in organizations] + ["Create New Organization"], @@ -244,14 +247,22 @@ def config(): name=org_name, description=org_description, ) - organization = api.create_organization(organization=organization_create) + organization = _api_call( + "Could not create the organization", + api.create_organization, + organization=organization_create, + ) print(f"Created organization : {organization}") else: organization = [orga for orga in organizations if orga["name"] == org][0] org_id = organization["id"] overwrite_local_config("organization_id", org_id, path=file_path) - projects = api.list_projects_from_organization(org_id) + projects = _api_call( + "Could not list projects from API", + api.list_projects_from_organization, + org_id, + ) project_names = [project["name"] for project in projects] project = questionary_prompt( "Pick existing project from list or Create new project ?", @@ -268,14 +279,20 @@ def config(): description=project_description, organization_id=org_id, ) - project = api.create_project(project=project_create) + project = _api_call( + "Could not create the project", api.create_project, project=project_create + ) print(f"Created project : {project}") else: project = [p for p in projects if p["name"] == project][0] project_id = project["id"] overwrite_local_config("project_id", project_id, path=file_path) - experiments = api.list_experiments_from_project(project_id) + experiments = _api_call( + "Could not list experiments from API", + api.list_experiments_from_project, + project_id, + ) experiments_names = [experiment["name"] for experiment in experiments] experiment = questionary_prompt( @@ -323,13 +340,17 @@ def config(): cloud_provider=cloud_provider, cloud_region=cloud_region, ) - experiment = api.add_experiment(experiment=experiment_create) + experiment = _api_call( + "Could not create the experiment", + api.add_experiment, + experiment=experiment_create, + ) else: experiment = [e for e in experiments if e["name"] == experiment][0] overwrite_local_config("experiment_id", experiment["id"], path=file_path) - api_key = get_api_key(project_id) + api_key = _api_call("Could not get the project API key", get_api_key, project_id) overwrite_local_config("api_key", api_key, path=file_path) show_config(file_path) print( diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index 3d031cfc5..22a60fcb5 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -229,6 +229,7 @@ def add_emission(self, carbon_emission: dict): self._raise_api_error(url, payload, r) logger.debug(f"ApiClient - Successful upload emission {payload} to {url}") except requests.exceptions.HTTPError: + # Already logged by _raise_api_error, do not log it twice. raise except Exception as e: logger.error(e, exc_info=True) @@ -284,6 +285,7 @@ def _create_run(self, experiment_id: str): ) raise except requests.exceptions.HTTPError: + # Already logged by _raise_api_error, do not log it twice. raise except Exception as e: logger.error(e, exc_info=True) @@ -331,6 +333,9 @@ def get_experiment(self, experiment_id): return r.json() def _raise_api_error(self, url, payload, response): + """ + Log the failed call then always raise a requests.exceptions.HTTPError. + """ if len(payload) > 0: logger.error( f"ApiClient Error when calling the API on {url} with : {json.dumps(payload)}" diff --git a/tests/cli/test_cli_main.py b/tests/cli/test_cli_main.py index b0c3ec593..8bb4d66f4 100644 --- a/tests/cli/test_cli_main.py +++ b/tests/cli/test_cli_main.py @@ -230,6 +230,9 @@ def test_get_api_key_uses_bearer_token(monkeypatch): captured = {} class FakeResponse: + def raise_for_status(self): + return None + def json(self): return {"token": "project-api-token"} @@ -249,6 +252,43 @@ def fake_post(url, json, headers): assert captured["headers"]["Authorization"] == "Bearer access-token" +def test_get_api_key_raises_on_http_error(monkeypatch): + class FailingResponse: + def raise_for_status(self): + raise requests.exceptions.HTTPError("403 Forbidden") + + def json(self): # pragma: no cover - must not be reached + raise AssertionError("json() should not be called on a failed response") + + monkeypatch.setattr("codecarbon.cli.auth.get_access_token", lambda: "access-token") + monkeypatch.setattr("requests.post", lambda url, json, headers: FailingResponse()) + + with pytest.raises(requests.exceptions.HTTPError): + cli_main.get_api_key("proj-123") + + +def test_api_call_prints_friendly_error_and_exits(): + def failing(): + raise requests.exceptions.HTTPError("500 Server Error") + + with pytest.raises(typer.Exit) as exc_info: + cli_main._api_call("Could not do the thing", failing) + assert exc_info.value.exit_code == 1 + + +def test_api_call_returns_result_and_forwards_arguments(): + captured = {} + + def succeeding(*args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return "ok" + + assert cli_main._api_call("unused", succeeding, "org-1", name="test") == "ok" + assert captured["args"] == ("org-1",) + assert captured["kwargs"] == {"name": "test"} + + def test_get_token_command_prints_token(monkeypatch): monkeypatch.setattr(cli_main, "get_api_key", lambda project_id: "abc123") runner = CliRunner() From 12418d91cb6bbc5152094bf711422701868df301 Mon Sep 17 00:00:00 2001 From: benoit-cty Date: Mon, 3 Aug 2026 22:13:12 +0200 Subject: [PATCH 6/7] Fix test coverage --- tests/test_api_call.py | 125 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/tests/test_api_call.py b/tests/test_api_call.py index a5178f7b3..d3b5bd96f 100644 --- a/tests/test_api_call.py +++ b/tests/test_api_call.py @@ -6,7 +6,7 @@ import requests_mock from codecarbon.core.api_client import ApiClient -from codecarbon.core.schemas import ExperimentCreate, OrganizationCreate +from codecarbon.core.schemas import ExperimentCreate, OrganizationCreate, ProjectCreate from codecarbon.output import EmissionsData conf = { @@ -378,3 +378,126 @@ def test_create_run_raises_on_connection_error(self): api_key="Toto", conf=conf, ) + + def test_create_run_raises_on_unexpected_error(self): + """A non HTTP, non connection error is logged then re-raised as is.""" + with requests_mock.Mocker() as m: + m.post("http://test.com/runs", exc=requests.exceptions.Timeout("too slow")) + with self.assertRaises(requests.exceptions.Timeout): + ApiClient( + experiment_id="experiment_id", + endpoint_url="http://test.com", + api_key="Toto", + conf=conf, + ) + + def test_add_emission_raises_on_unexpected_error(self): + """A non HTTP error raised while posting an emission is re-raised as is.""" + with requests_mock.Mocker() as m: + m.post( + "http://test.com/emissions", exc=requests.exceptions.Timeout("too slow") + ) + api = ApiClient( + endpoint_url="http://test.com", + experiment_id="exp-1", + conf=conf, + create_run_automatically=False, + ) + api.run_id = "run-1" + + with self.assertRaises(requests.exceptions.Timeout): + api.add_emission( + { + "duration": 2, + "emissions": 1.0, + "emissions_rate": 1.0, + "cpu_power": 1.0, + "gpu_power": 0.0, + "ram_power": 0.5, + "cpu_energy": 0.1, + "gpu_energy": 0.0, + "ram_energy": 0.1, + "energy_consumed": 0.2, + } + ) + + def test_create_organization_raises_on_error(self): + organization = OrganizationCreate(name="new-org", description="desc") + + with requests_mock.Mocker() as m: + # No organization with that name yet, so the creation is attempted. + m.get("http://test.com/organizations", json=[], status_code=200) + m.post("http://test.com/organizations", text="bad", status_code=500) + api = ApiClient( + endpoint_url="http://test.com", + create_run_automatically=False, + ) + + with self.assertRaises(requests.exceptions.HTTPError): + api.create_organization(organization) + + def test_get_organization_raises_on_error(self): + with requests_mock.Mocker() as m: + m.get("http://test.com/organizations/org-1", text="bad", status_code=404) + api = ApiClient( + endpoint_url="http://test.com", + create_run_automatically=False, + ) + + with self.assertRaises(requests.exceptions.HTTPError): + api.get_organization("org-1") + + def test_update_organization_raises_on_error(self): + organization = OrganizationCreate(name="org", description="desc") + organization.id = "org-1" + + with requests_mock.Mocker() as m: + m.patch("http://test.com/organizations/org-1", text="bad", status_code=500) + api = ApiClient( + endpoint_url="http://test.com", + create_run_automatically=False, + ) + + with self.assertRaises(requests.exceptions.HTTPError): + api.update_organization(organization) + + def test_list_projects_from_organization_raises_on_error(self): + with requests_mock.Mocker() as m: + m.get( + "http://test.com/organizations/org-1/projects", + text="bad", + status_code=500, + ) + api = ApiClient( + endpoint_url="http://test.com", + create_run_automatically=False, + ) + + with self.assertRaises(requests.exceptions.HTTPError): + api.list_projects_from_organization("org-1") + + def test_create_project_raises_on_error(self): + project = ProjectCreate( + name="project", description="desc", organization_id="org-1" + ) + + with requests_mock.Mocker() as m: + m.post("http://test.com/projects", text="bad", status_code=500) + api = ApiClient( + endpoint_url="http://test.com", + create_run_automatically=False, + ) + + with self.assertRaises(requests.exceptions.HTTPError): + api.create_project(project) + + def test_get_project_raises_on_error(self): + with requests_mock.Mocker() as m: + m.get("http://test.com/projects/proj-1", text="bad", status_code=404) + api = ApiClient( + endpoint_url="http://test.com", + create_run_automatically=False, + ) + + with self.assertRaises(requests.exceptions.HTTPError): + api.get_project("proj-1") From 93e72ad6aeb81a9df05f5e4349abf94dc64c982c Mon Sep 17 00:00:00 2001 From: benoit-cty Date: Mon, 3 Aug 2026 22:20:54 +0200 Subject: [PATCH 7/7] refactor: route every ApiClient call through a _request helper Every method repeated the same four lines: build the headers, call requests, compare the status code, log and raise. They now all delegate to a single `_request(method, url, payload, expected_status)`, which removes 44 lines from the module. Behaviour is unchanged: `_request` calls `_raise_api_error` on an unexpected status, so the errors raised, the logs and the re-raise clauses of `add_emission` and `_create_run` all stay as they were. The helper comes from the alternative fix proposed in #1288, which targets the same issue. Co-authored-by: PSR94 <88868390+PSR94@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/core/api_client.py | 97 ++++++++++++----------------------- 1 file changed, 34 insertions(+), 63 deletions(-) diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index 22a60fcb5..eaef94a53 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -70,6 +70,21 @@ def _get_headers(self): headers["Authorization"] = f"Bearer {self.access_token}" return headers + def _request(self, method, url, payload=None, expected_status=200): + """ + Call the API and return the response, raising on anything that is not + the status code the API answers on success. + + :method: the requests function to call, for example requests.get + :payload: the JSON body to send, if any + :expected_status: the http code the API returns when the call succeeds + """ + headers = self._get_headers() + response = method(url=url, json=payload, timeout=2, headers=headers) + if response.status_code != expected_status: + self._raise_api_error(url, payload or {}, response) + return response + def set_access_token(self, token: str): """This method sets the access token to be used for the API. Args: @@ -82,22 +97,14 @@ def check_auth(self): Check API access to user account """ url = self.url + "/auth/check" - headers = self._get_headers() - r = requests.get(url=url, timeout=2, headers=headers) - if r.status_code != 200: - self._raise_api_error(url, {}, r) - return r.json() + return self._request(requests.get, url).json() def get_list_organizations(self): """ List all organizations """ url = self.url + "/organizations" - headers = self._get_headers() - r = requests.get(url=url, timeout=2, headers=headers) - if r.status_code != 200: - self._raise_api_error(url, {}, r) - return r.json() + return self._request(requests.get, url).json() def check_organization_exists(self, organization_name: str): """ @@ -121,45 +128,31 @@ def create_organization(self, organization: OrganizationCreate): ) return organization else: - headers = self._get_headers() - r = requests.post(url=url, json=payload, timeout=2, headers=headers) - if r.status_code != 201: - self._raise_api_error(url, payload, r) - return r.json() + return self._request( + requests.post, url, payload=payload, expected_status=201 + ).json() def get_organization(self, organization_id): """ Get an organization """ - headers = self._get_headers() url = self.url + "/organizations/" + organization_id - r = requests.get(url=url, timeout=2, headers=headers) - if r.status_code != 200: - self._raise_api_error(url, {}, r) - return r.json() + return self._request(requests.get, url).json() def update_organization(self, organization: OrganizationCreate): """ Update an organization """ payload = dataclasses.asdict(organization) - headers = self._get_headers() url = self.url + "/organizations/" + organization.id - r = requests.patch(url=url, json=payload, timeout=2, headers=headers) - if r.status_code != 200: - self._raise_api_error(url, payload, r) - return r.json() + return self._request(requests.patch, url, payload=payload).json() def list_projects_from_organization(self, organization_id): """ List all projects """ url = self.url + "/organizations/" + organization_id + "/projects" - headers = self._get_headers() - r = requests.get(url=url, timeout=2, headers=headers) - if r.status_code != 200: - self._raise_api_error(url, {}, r) - return r.json() + return self._request(requests.get, url).json() def create_project(self, project: ProjectCreate): """ @@ -167,22 +160,16 @@ def create_project(self, project: ProjectCreate): """ payload = dataclasses.asdict(project) url = self.url + "/projects" - headers = self._get_headers() - r = requests.post(url=url, json=payload, timeout=2, headers=headers) - if r.status_code != 201: - self._raise_api_error(url, payload, r) - return r.json() + return self._request( + requests.post, url, payload=payload, expected_status=201 + ).json() def get_project(self, project_id): """ Get a project """ url = self.url + "/projects/" + project_id - headers = self._get_headers() - r = requests.get(url=url, timeout=2, headers=headers) - if r.status_code != 200: - self._raise_api_error(url, {}, r) - return r.json() + return self._request(requests.get, url).json() def add_emission(self, carbon_emission: dict): assert self.experiment_id is not None @@ -223,10 +210,7 @@ def add_emission(self, carbon_emission: dict): try: payload = dataclasses.asdict(emission) url = self.url + "/emissions" - headers = self._get_headers() - r = requests.post(url=url, json=payload, timeout=2, headers=headers) - if r.status_code != 201: - self._raise_api_error(url, payload, r) + self._request(requests.post, url, payload=payload, expected_status=201) logger.debug(f"ApiClient - Successful upload emission {payload} to {url}") except requests.exceptions.HTTPError: # Already logged by _raise_api_error, do not log it twice. @@ -267,10 +251,7 @@ def _create_run(self, experiment_id: str): ) payload = dataclasses.asdict(run) url = self.url + "/runs" - headers = self._get_headers() - r = requests.post(url=url, json=payload, timeout=2, headers=headers) - if r.status_code != 201: - self._raise_api_error(url, payload, r) + r = self._request(requests.post, url, payload=payload, expected_status=201) self.run_id = r.json()["id"] logger.info( "ApiClient Successfully registered your run on the API.\n\n" @@ -296,11 +277,7 @@ def list_experiments_from_project(self, project_id: str): List all experiments for a project """ url = self.url + "/projects/" + project_id + "/experiments" - headers = self._get_headers() - r = requests.get(url=url, timeout=2, headers=headers) - if r.status_code != 200: - self._raise_api_error(url, {}, r) - return r.json() + return self._request(requests.get, url).json() def set_experiment(self, experiment_id: str): """ @@ -315,22 +292,16 @@ def add_experiment(self, experiment: ExperimentCreate): """ payload = dataclasses.asdict(experiment) url = self.url + "/experiments" - headers = self._get_headers() - r = requests.post(url=url, json=payload, timeout=2, headers=headers) - if r.status_code != 201: - self._raise_api_error(url, payload, r) - return r.json() + return self._request( + requests.post, url, payload=payload, expected_status=201 + ).json() def get_experiment(self, experiment_id): """ Get an experiment by id """ url = self.url + "/experiments/" + experiment_id - headers = self._get_headers() - r = requests.get(url=url, timeout=2, headers=headers) - if r.status_code != 200: - self._raise_api_error(url, {}, r) - return r.json() + return self._request(requests.get, url).json() def _raise_api_error(self, url, payload, response): """