diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index c10b32338..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,7 +130,7 @@ 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() + organizations = _api_call("API request failed", api.get_list_organizations) print(organizations) @@ -130,7 +144,7 @@ def login(): api = ApiClient(endpoint_url=api_endpoint) access_token = get_access_token() api.set_access_token(access_token) - api.check_auth() + _api_call("Authentication check failed", api.check_auth) def get_api_key(project_id: str): @@ -149,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 @@ -212,7 +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()) - organizations = api.get_list_organizations() + 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"], @@ -229,18 +247,23 @@ def config(): name=org_name, description=org_description, ) - organization = api.create_organization(organization=organization_create) - if organization is None: - print("Error creating organization") - return + 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) - project_names = [project["name"] for project in projects] if projects else [] + 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 ?", project_names + ["Create New Project"], @@ -256,17 +279,21 @@ 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_names = ( - [experiment["name"] for experiment in experiments] if experiments else [] + 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( "Pick existing experiment from list or Create new experiment ?", @@ -313,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 58f4932cb..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,32 +97,20 @@ 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._log_error(url, {}, r) - return None - 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._log_error(url, {}, r) - return None - return r.json() + return self._request(requests.get, url).json() 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 @@ -125,49 +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._log_error(url, payload, r) - return None - 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._log_error(url, {}, r) - return None - 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._log_error(url, payload, r) - return None - 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._log_error(url, {}, r) - return None - return r.json() + return self._request(requests.get, url).json() def create_project(self, project: ProjectCreate): """ @@ -175,24 +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._log_error(url, payload, r) - return None - 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._log_error(url, {}, r) - return None - return r.json() + return self._request(requests.get, url).json() def add_emission(self, carbon_emission: dict): assert self.experiment_id is not None @@ -233,15 +210,14 @@ 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._log_error(url, payload, r) - return False + 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. + raise except Exception as e: logger.error(e, exc_info=True) - return False + raise return True def _create_run(self, experiment_id: str): @@ -275,11 +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._log_error(url, payload, r) - return None + 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" @@ -292,20 +264,20 @@ def _create_run(self, experiment_id: str): f"Failed to connect to API, please check the configuration. {e}", exc_info=False, ) + 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) + raise 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._log_error(url, {}, r) - return [] - return r.json() + return self._request(requests.get, url).json() def set_experiment(self, experiment_id: str): """ @@ -320,26 +292,21 @@ 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._log_error(url, payload, r) - return None - 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._log_error(url, {}, r) - return None - return r.json() + return self._request(requests.get, url).json() - def _log_error(self, url, payload, response): + 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)}" @@ -349,6 +316,11 @@ 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() + # 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..8bb4d66f4 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,10 +201,38 @@ 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 = {} class FakeResponse: + def raise_for_status(self): + return None + def json(self): return {"token": "project-api-token"} @@ -208,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() diff --git a/tests/test_api_call.py b/tests/test_api_call.py index 39822ece7..d3b5bd96f 100644 --- a/tests/test_api_call.py +++ b/tests/test_api_call.py @@ -2,10 +2,11 @@ import unittest from uuid import uuid4 +import requests 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 = { @@ -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,55 @@ 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_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_list_experiments_from_project_returns_empty_list_on_error(self): + 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( "http://test.com/projects/proj-1/experiments", @@ -279,7 +326,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 +336,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 +351,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 +362,142 @@ 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, + ) + + 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")