From a5c5724d012fe945bdd6ae86fe44802bf4c428b1 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Thu, 20 Aug 2026 08:12:46 +0200 Subject: [PATCH 1/3] fix: send and store real sub-second emission durations The `duration` field was typed `int` in the pydantic schemas while the DB column and ORM were always `Float`, so every duration sent to the API was truncated to whole seconds. `ApiClient.add_emission` compounded this by dropping any measurement shorter than one second outright, which silently discarded data from short tasks and from trackers running a small `measure_power_secs`. Type the schemas as float to match the column, and stop rejecting non-integer durations. Emissions with a non-positive duration are still skipped, since those carry no measurement. Co-Authored-By: Claude Opus 5 (1M context) --- carbonserver/carbonserver/api/schemas.py | 8 +- .../tests/api/test_schema_compatibility.py | 24 ++++++ codecarbon/core/api_client.py | 7 +- codecarbon/core/schemas.py | 2 +- tests/test_api_call.py | 80 +++++++++++++------ 5 files changed, 90 insertions(+), 31 deletions(-) diff --git a/carbonserver/carbonserver/api/schemas.py b/carbonserver/carbonserver/api/schemas.py index a3343929d..74b267052 100644 --- a/carbonserver/carbonserver/api/schemas.py +++ b/carbonserver/carbonserver/api/schemas.py @@ -56,7 +56,7 @@ def __repr__(self): class EmissionBase(BaseModel): timestamp: datetime run_id: UUID - duration: int = Field( + duration: float = Field( ..., gt=0, description="The duration must be greater than zero" ) emissions_sum: Optional[float] = Field( @@ -283,7 +283,7 @@ class ExperimentReport(ExperimentBase): gpu_energy: float ram_energy: float energy_consumed: float - duration: int + duration: float emissions_rate: float emissions_count: int cpu_utilization_percent: Optional[float] = None @@ -396,7 +396,7 @@ class ProjectReport(ProjectBase): gpu_energy: float ram_energy: float energy_consumed: float - duration: int + duration: float emissions_rate: float emissions_count: int cpu_utilization_percent: Optional[float] = None @@ -443,7 +443,7 @@ class OrganizationReport(OrganizationBase): gpu_energy: float ram_energy: float energy_consumed: float - duration: int + duration: float emissions_rate: float emissions_count: int cpu_utilization_percent: Optional[float] = None diff --git a/carbonserver/tests/api/test_schema_compatibility.py b/carbonserver/tests/api/test_schema_compatibility.py index 94522fc8c..161e5097a 100644 --- a/carbonserver/tests/api/test_schema_compatibility.py +++ b/carbonserver/tests/api/test_schema_compatibility.py @@ -118,3 +118,27 @@ def test_client_create_payloads_validate_against_server_schemas( client_payload, server_schema ): server_schema.model_validate(dataclasses.asdict(client_payload)) + + +def test_millisecond_duration_survives_client_to_server(): + """A single FastAPI request lasts milliseconds and must not be rounded away.""" + payload = client_schemas.EmissionCreate( + timestamp="2021-04-04T08:43:00+02:00", + run_id="40088f1a-d28e-4980-8d80-bf5600056a14", + duration=0.0042, + emissions_sum=1544.54, + emissions_rate=1.548444, + cpu_power=0.3, + gpu_power=0.0, + ram_power=0.15, + cpu_energy=55.21874, + gpu_energy=0.0, + ram_energy=2.0, + energy_consumed=57.21874, + ) + + validated = server_schemas.EmissionCreate.model_validate( + dataclasses.asdict(payload) + ) + + assert validated.duration == 0.0042 diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index bc2e0974e..b9116f3ac 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -189,15 +189,16 @@ def add_emission(self, carbon_emission: dict): "ApiClient.add_emission still no run_id, aborting for this time !" ) return False - if carbon_emission["duration"] < 1: + if carbon_emission["duration"] <= 0: + # The server declares duration as gt=0, so this would 422. logger.warning( - "ApiClient : emissions not sent because of a duration smaller than 1." + "ApiClient : emissions not sent because the duration is not positive." ) return False emission = EmissionCreate( timestamp=get_datetime_with_timezone(), run_id=self.run_id, - duration=int(carbon_emission["duration"]), + duration=carbon_emission["duration"], emissions_sum=carbon_emission["emissions"], emissions_rate=carbon_emission["emissions_rate"], cpu_power=carbon_emission["cpu_power"], diff --git a/codecarbon/core/schemas.py b/codecarbon/core/schemas.py index 84d1e9c77..9cc6b85a7 100644 --- a/codecarbon/core/schemas.py +++ b/codecarbon/core/schemas.py @@ -12,7 +12,7 @@ class EmissionBase: timestamp: str run_id: str - duration: int + duration: float emissions_sum: float emissions_rate: float cpu_power: float diff --git a/tests/test_api_call.py b/tests/test_api_call.py index 31e25c039..627e1dca8 100644 --- a/tests/test_api_call.py +++ b/tests/test_api_call.py @@ -235,31 +235,65 @@ def test_add_emission_returns_false_when_run_creation_fails(self): ) ) - def test_add_emission_skips_short_duration(self): - api = ApiClient( - endpoint_url="http://test.com", - experiment_id="exp-1", - conf=conf, - create_run_automatically=False, - ) - api.run_id = "run-1" + def test_add_emission_sends_millisecond_duration_unchanged(self): + """A single FastAPI request lasts milliseconds: send it, do not round it.""" + with requests_mock.Mocker() as m: + m.post("http://test.com/emissions", json={"id": "em-1"}, status_code=201) + api = ApiClient( + endpoint_url="http://test.com", + experiment_id="exp-1", + conf=conf, + create_run_automatically=False, + ) + api.run_id = "run-1" - self.assertFalse( - api.add_emission( - { - "duration": 0.5, - "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.assertTrue( + api.add_emission( + { + "duration": 0.0042, + "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.assertEqual(m.last_request.json()["duration"], 0.0042) + + def test_add_emission_skips_zero_duration(self): + """A zero-length flush would be a 422: the server requires duration > 0.""" + with requests_mock.Mocker() as m: + m.post("http://test.com/emissions", json={"id": "em-1"}, status_code=201) + api = ApiClient( + endpoint_url="http://test.com", + experiment_id="exp-1", + conf=conf, + create_run_automatically=False, + ) + api.run_id = "run-1" + + self.assertFalse( + api.add_emission( + { + "duration": 0.0, + "emissions": 0.0, + "emissions_rate": 0.0, + "cpu_power": 1.0, + "gpu_power": 0.0, + "ram_power": 0.5, + "cpu_energy": 0.0, + "gpu_energy": 0.0, + "ram_energy": 0.0, + "energy_consumed": 0.0, + } + ) + ) + self.assertFalse(m.called) def test_add_emission_raises_on_unsuccessful_post(self): with requests_mock.Mocker() as m: From b71cd53645d88880faeb9d5e08aad5b417ddd43f Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 23 Sep 2026 16:38:53 +0900 Subject: [PATCH 2/3] test: reword duration test docstrings Co-Authored-By: Claude Opus 5.5 (1M context) --- carbonserver/tests/api/test_schema_compatibility.py | 2 +- tests/test_api_call.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/carbonserver/tests/api/test_schema_compatibility.py b/carbonserver/tests/api/test_schema_compatibility.py index 161e5097a..1acce76ca 100644 --- a/carbonserver/tests/api/test_schema_compatibility.py +++ b/carbonserver/tests/api/test_schema_compatibility.py @@ -121,7 +121,7 @@ def test_client_create_payloads_validate_against_server_schemas( def test_millisecond_duration_survives_client_to_server(): - """A single FastAPI request lasts milliseconds and must not be rounded away.""" + """A sub-second duration is stored as is, not rounded away.""" payload = client_schemas.EmissionCreate( timestamp="2021-04-04T08:43:00+02:00", run_id="40088f1a-d28e-4980-8d80-bf5600056a14", diff --git a/tests/test_api_call.py b/tests/test_api_call.py index 627e1dca8..8a2d6a8ba 100644 --- a/tests/test_api_call.py +++ b/tests/test_api_call.py @@ -236,7 +236,7 @@ def test_add_emission_returns_false_when_run_creation_fails(self): ) def test_add_emission_sends_millisecond_duration_unchanged(self): - """A single FastAPI request lasts milliseconds: send it, do not round it.""" + """A sub-second duration is sent as is, not rounded.""" with requests_mock.Mocker() as m: m.post("http://test.com/emissions", json={"id": "em-1"}, status_code=201) api = ApiClient( From 0ddda1a5c32b999175563ccd9b89c85327ffb134 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 23 Sep 2026 17:00:51 +0900 Subject: [PATCH 3/3] fix(api): retry emission with rounded duration when server rejects floats Servers older than this client declare duration as int and answer 422 on fractional seconds. Retry once with a rounded duration so emissions are not dropped. Co-Authored-By: Claude Opus 5.5 (1M context) --- codecarbon/core/api_client.py | 18 +++++++++++++++++- tests/test_api_call.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index b9116f3ac..88a321545 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -216,7 +216,23 @@ def add_emission(self, carbon_emission: dict): try: payload = dataclasses.asdict(emission) url = self.url + "/emissions" - self._request(requests.post, url, payload=payload, expected_status=201) + response = requests.post( + url=url, json=payload, timeout=2, headers=self._get_headers() + ) + duration = payload["duration"] + if response.status_code == 422 and duration != round(duration): + # Servers older than the client declare duration as an int and + # reject fractional seconds: retry once with a rounded value. + logger.info( + "ApiClient : the API rejected a fractional duration, it looks" + " older than this client. Retrying with a rounded duration." + ) + payload["duration"] = max(1, round(duration)) + response = requests.post( + url=url, json=payload, timeout=2, headers=self._get_headers() + ) + if response.status_code != 201: + self._raise_api_error(url, payload, response) 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. diff --git a/tests/test_api_call.py b/tests/test_api_call.py index 8a2d6a8ba..738593fb7 100644 --- a/tests/test_api_call.py +++ b/tests/test_api_call.py @@ -138,6 +138,39 @@ def test_call_api(self): assert payload["ram_utilization_percent"] == 56.5 assert payload["wue"] == 0.8 + def test_add_emission_retries_rounded_duration_on_old_server(self): + emission = { + "duration": 15.0023, + "emissions": 2.0, + "emissions_rate": 2.0, + "cpu_power": 3.0, + "gpu_power": 0, + "ram_power": 0.15, + "cpu_energy": 2, + "gpu_energy": 0, + "ram_energy": 1, + "energy_consumed": 3.0, + } + with requests_mock.Mocker() as m: + m.post( + "http://test.com/emissions", + [{"status_code": 422}, {"status_code": 201}], + ) + api = ApiClient( + endpoint_url="http://test.com", + experiment_id="exp-1", + create_run_automatically=False, + ) + api.run_id = "run-1" + + self.assertTrue(api.add_emission(emission)) + + self.assertEqual(m.call_count, 2) + self.assertEqual(m.request_history[0].json()["duration"], 15.0023) + retried = m.request_history[1].json()["duration"] + self.assertIsInstance(retried, int) + self.assertEqual(retried, 15) + def test_create_run_rounds_coordinates(self): with requests_mock.Mocker() as m: m.post("http://test.com/runs", json={"id": "run-1"}, status_code=201)