Skip to content
This repository was archived by the owner on Jul 16, 2025. It is now read-only.

Commit c42fe0b

Browse files
chore: better handle CancelledError from static analysis (#275)
* chore: handle internal `send_single_upload_put` exceptions Instead of failing (and raising) in the case of a request exception (in `send_single_upload_put`) we will now catch the exception, emit warning, and return safely. This is important because the aggregate of all tasks WILL fail if one of the requests fail. Also notice that `httpx.HTTPError` is the base error class for a number of request-related errors, so this covers many of the failure possibilities here * chore: better handle CancelledError from static analysis We have seen stacktraces of static analysis failing with `asyncio.exceptions.CancelledError: Cancelled by cancel scope 7f7514b8ffa0` I do believe that the initial cause is one of the requests failing, and that cancels every other task, but there's a still possible scenario in which the outer future (`asyncio.gather itself`) gets cancelled. Assuming that it happens and that we do get the same exception bubble up, these changes would let the program exit (more) gracefully. Maybe there are other exceptions there that we should also look for?
1 parent 00e49a5 commit c42fe0b

File tree

2 files changed

+205
-21
lines changed

2 files changed

+205
-21
lines changed

codecov_cli/services/staticanalysis/__init__.py

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,14 @@ async def run_analysis_entrypoint(
109109
for el in files_that_need_upload:
110110
all_tasks.append(send_single_upload_put(client, all_data, el))
111111
bar.update(1, all_data[el["filepath"]])
112-
resps = await asyncio.gather(*all_tasks)
112+
try:
113+
resps = await asyncio.gather(*all_tasks)
114+
except asyncio.CancelledError:
115+
message = (
116+
"Unknown error cancelled the upload tasks.\n"
117+
+ f"Uploaded {len(uploaded_files)}/{len(files_that_need_upload)} files successfully."
118+
)
119+
raise click.ClickException(message)
113120
for resp in resps:
114121
if resp["succeeded"]:
115122
uploaded_files.append(resp["filepath"])
@@ -188,35 +195,44 @@ async def process_files(
188195
)
189196

190197

191-
async def send_single_upload_put(client, all_data, el):
198+
async def send_single_upload_put(client, all_data, el) -> typing.Dict:
192199
retryable_statuses = (429,)
193200
presigned_put = el["raw_upload_location"]
194201
number_retries = 5
195-
for current_retry in range(number_retries):
196-
response = await client.put(
197-
presigned_put, data=json.dumps(all_data[el["filepath"]])
198-
)
199-
if response.status_code < 300:
200-
return {
201-
"status_code": response.status_code,
202-
"filepath": el["filepath"],
203-
"succeeded": True,
204-
}
205-
if response.status_code in retryable_statuses:
206-
await asyncio.sleep(2**current_retry)
202+
try:
203+
for current_retry in range(number_retries):
204+
response = await client.put(
205+
presigned_put, data=json.dumps(all_data[el["filepath"]])
206+
)
207+
if response.status_code < 300:
208+
return {
209+
"status_code": response.status_code,
210+
"filepath": el["filepath"],
211+
"succeeded": True,
212+
}
213+
if response.status_code in retryable_statuses:
214+
await asyncio.sleep(2**current_retry)
215+
status_code = response.status_code
216+
message_to_warn = response.text
217+
exception = None
218+
except httpx.HTTPError as exp:
219+
status_code = None
220+
exception = type(exp)
221+
message_to_warn = str(exp)
207222
logger.warning(
208-
"Unable to send data",
223+
"Unable to send single_upload_put",
209224
extra=dict(
210225
extra_log_attributes=dict(
211-
response=response.text,
226+
message=message_to_warn,
227+
exception=exception,
212228
filepath=el["filepath"],
213-
url=presigned_put,
214-
latest_status_code=response.status_code,
229+
latest_status_code=status_code,
215230
)
216231
),
217232
)
218233
return {
219-
"status_code": response.status_code,
234+
"status_code": status_code,
235+
"exception": exception,
220236
"filepath": el["filepath"],
221237
"succeeded": False,
222238
}

tests/services/static_analysis/test_static_analysis_service.py

Lines changed: 170 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1+
from asyncio import CancelledError
12
from pathlib import Path
23
from unittest.mock import MagicMock
34

45
import click
6+
import httpx
57
import pytest
68
import requests
79
import responses
810
from responses import matchers
911

10-
from codecov_cli.services.staticanalysis import process_files, run_analysis_entrypoint
12+
from codecov_cli.services.staticanalysis import (
13+
process_files,
14+
run_analysis_entrypoint,
15+
send_single_upload_put,
16+
)
1117
from codecov_cli.services.staticanalysis.types import (
1218
FileAnalysisRequest,
1319
FileAnalysisResult,
@@ -67,7 +73,7 @@ def imap_side_effect(mapped_func, files):
6773
)
6874

6975
@pytest.mark.asyncio
70-
async def test_static_analysis_service(self, mocker):
76+
async def test_static_analysis_service_success(self, mocker):
7177
mock_file_finder = mocker.patch(
7278
"codecov_cli.services.staticanalysis.select_file_finder"
7379
)
@@ -143,6 +149,168 @@ async def side_effect(*args, **kwargs):
143149
"raw_upload_location": "http://storage-url",
144150
}
145151

152+
@pytest.mark.asyncio
153+
async def test_static_analysis_service_CancelledError(self, mocker):
154+
mock_file_finder = mocker.patch(
155+
"codecov_cli.services.staticanalysis.select_file_finder"
156+
)
157+
mock_send_upload_put = mocker.patch(
158+
"codecov_cli.services.staticanalysis.send_single_upload_put"
159+
)
160+
161+
async def side_effect(client, all_data, el):
162+
if el["filepath"] == "samples/inputs/sample_001.py":
163+
return {
164+
"status_code": 204,
165+
"filepath": el["filepath"],
166+
"succeeded": True,
167+
}
168+
raise CancelledError("Pretending something cancelled this task")
169+
170+
mock_send_upload_put.side_effect = side_effect
171+
172+
files_found = map(
173+
lambda filename: FileAnalysisRequest(str(filename), Path(filename)),
174+
[
175+
"samples/inputs/sample_001.py",
176+
"samples/inputs/sample_002.py",
177+
],
178+
)
179+
mock_file_finder.return_value.find_files = MagicMock(return_value=files_found)
180+
with responses.RequestsMock() as rsps:
181+
rsps.add(
182+
responses.POST,
183+
"https://api.codecov.io/staticanalysis/analyses",
184+
json={
185+
"external_id": "externalid",
186+
"filepaths": [
187+
{
188+
"state": "created",
189+
"filepath": "samples/inputs/sample_001.py",
190+
"raw_upload_location": "http://storage-url-001",
191+
},
192+
{
193+
"state": "created",
194+
"filepath": "samples/inputs/sample_002.py",
195+
"raw_upload_location": "http://storage-url-002",
196+
},
197+
],
198+
},
199+
status=200,
200+
match=[
201+
matchers.header_matcher({"Authorization": "Repotoken STATIC_TOKEN"})
202+
],
203+
)
204+
205+
with pytest.raises(click.ClickException) as exp:
206+
await run_analysis_entrypoint(
207+
config={},
208+
folder=".",
209+
numberprocesses=1,
210+
pattern="*.py",
211+
token="STATIC_TOKEN",
212+
commit="COMMIT",
213+
should_force=False,
214+
folders_to_exclude=[],
215+
enterprise_url=None,
216+
)
217+
assert "Unknown error cancelled the upload tasks." in str(exp.value)
218+
mock_file_finder.assert_called_with({})
219+
mock_file_finder.return_value.find_files.assert_called()
220+
assert mock_send_upload_put.call_count == 2
221+
222+
@pytest.mark.asyncio
223+
async def test_send_single_upload_put_success(self, mocker):
224+
mock_client = MagicMock()
225+
226+
async def side_effect(presigned_put, data):
227+
if presigned_put == "http://storage-url-001":
228+
return httpx.Response(status_code=204)
229+
230+
mock_client.put.side_effect = side_effect
231+
232+
all_data = {
233+
"file-001": {"some": "data", "id": "1"},
234+
"file-002": {"some": "data", "id": "2"},
235+
"file-003": {"some": "data", "id": "3"},
236+
}
237+
238+
success_response = await send_single_upload_put(
239+
mock_client,
240+
all_data=all_data,
241+
el={
242+
"filepath": "file-001",
243+
"raw_upload_location": "http://storage-url-001",
244+
},
245+
)
246+
assert success_response == {
247+
"status_code": 204,
248+
"filepath": "file-001",
249+
"succeeded": True,
250+
}
251+
252+
@pytest.mark.asyncio
253+
async def test_send_single_upload_put_fail_401(self, mocker):
254+
mock_client = MagicMock()
255+
256+
async def side_effect(presigned_put, data):
257+
if presigned_put == "http://storage-url-002":
258+
return httpx.Response(status_code=401)
259+
260+
mock_client.put.side_effect = side_effect
261+
262+
all_data = {
263+
"file-001": {"some": "data", "id": "1"},
264+
"file-002": {"some": "data", "id": "2"},
265+
"file-003": {"some": "data", "id": "3"},
266+
}
267+
268+
fail_401_response = await send_single_upload_put(
269+
mock_client,
270+
all_data=all_data,
271+
el={
272+
"filepath": "file-002",
273+
"raw_upload_location": "http://storage-url-002",
274+
},
275+
)
276+
assert fail_401_response == {
277+
"status_code": 401,
278+
"exception": None,
279+
"filepath": "file-002",
280+
"succeeded": False,
281+
}
282+
283+
@pytest.mark.asyncio
284+
async def test_send_single_upload_put_fail_exception(self, mocker):
285+
mock_client = MagicMock()
286+
287+
async def side_effect(presigned_put, data):
288+
if presigned_put == "http://storage-url-003":
289+
raise httpx.HTTPError("Some error occured in the request")
290+
291+
mock_client.put.side_effect = side_effect
292+
293+
all_data = {
294+
"file-001": {"some": "data", "id": "1"},
295+
"file-002": {"some": "data", "id": "2"},
296+
"file-003": {"some": "data", "id": "3"},
297+
}
298+
299+
fail_401_response = await send_single_upload_put(
300+
mock_client,
301+
all_data=all_data,
302+
el={
303+
"filepath": "file-003",
304+
"raw_upload_location": "http://storage-url-003",
305+
},
306+
)
307+
assert fail_401_response == {
308+
"status_code": None,
309+
"exception": httpx.HTTPError,
310+
"filepath": "file-003",
311+
"succeeded": False,
312+
}
313+
146314
@pytest.mark.asyncio
147315
@pytest.mark.parametrize(
148316
"finish_endpoint_response,expected",

0 commit comments

Comments
 (0)