22
33A `POST /orgs/<org>/full-scans` upload can fail transiently (an HTTP 502/503/504/408, a
44dropped or reset connection, or a timeout) without the server having created the scan.
5- `Core.create_full_scan` retries those transient failures; these tests cover the retry
6- classification, the loop bounds, and that the temporary brotli-compressed facts files
7- survive until every attempt has finished.
5+ `Core.create_full_scan` retries the failures the SDK classifies as transient
6+ (`APIFailure.is_transient_error()`, socketdev>=3.3.0); these tests cover the retry
7+ decision, the loop bounds, and that the temporary brotli-compressed facts files survive
8+ until every attempt has finished.
89"""
910
1011import logging
2627 SOCKET_FACTS_BROTLI_FILENAME,
2728 SOCKET_FACTS_FILENAME,
2829 Core,
29- _is_transient_full_scan_upload_error,
3030)
3131
3232
@@ -46,13 +46,14 @@ def _success_response():
4646 return response
4747
4848
49- # Catch-all APIFailure messages as the SDK formats them (socketdev/core/api.py); only the
50- # embedded original_status_code distinguishes a transient 503/504/408 from e.g. a 400 .
49+ # Catch-all APIFailure as the SDK raises it for statuses without a dedicated class
50+ # (socketdev/core/api.py); the recorded status_code drives is_transient_error() .
5151def _catch_all_failure(status_code: int) -> APIFailure:
5252 return APIFailure(
5353 f"Bad Request: HTTP original_status_code:{status_code}\n"
5454 f"Path: https://api.socket.dev/v0/orgs/org/full-scans\n\n"
55- f"Headers:\ncf-ray: abc123"
55+ f"Headers:\ncf-ray: abc123",
56+ status_code=status_code,
5657 )
5758
5859
@@ -121,11 +122,14 @@ def test_upload_raises_after_exhausting_attempts(
121122 )
122123
123124
124- def test_upload_retries_on_catch_all_503(core_with_mock_sdk, tmp_path, no_sleep):
125+ @pytest.mark.parametrize("status_code", [408, 503, 504])
126+ def test_upload_retries_on_catch_all_transient_statuses(
127+ core_with_mock_sdk, tmp_path, no_sleep, status_code
128+ ):
125129 manifest = tmp_path / "package.json"
126130 manifest.write_text("{}")
127131 core_with_mock_sdk.sdk.fullscans.post.side_effect = [
128- _catch_all_failure(503 ),
132+ _catch_all_failure(status_code ),
129133 _success_response(),
130134 ]
131135
@@ -164,13 +168,17 @@ def test_upload_does_not_retry_on_400(core_with_mock_sdk, tmp_path, no_sleep):
164168 no_sleep.assert_not_called()
165169
166170
167- @pytest.mark.parametrize("error_class", [APIAccessDenied, APIResourceNotFound])
171+ @pytest.mark.parametrize(
172+ "error_class,status_code", [(APIAccessDenied, 401), (APIResourceNotFound, 404)]
173+ )
168174def test_upload_does_not_retry_on_dedicated_4xx_classes(
169- core_with_mock_sdk, tmp_path, no_sleep, error_class
175+ core_with_mock_sdk, tmp_path, no_sleep, error_class, status_code
170176):
171177 manifest = tmp_path / "package.json"
172178 manifest.write_text("{}")
173- core_with_mock_sdk.sdk.fullscans.post.side_effect = error_class()
179+ core_with_mock_sdk.sdk.fullscans.post.side_effect = error_class(
180+ status_code=status_code
181+ )
174182
175183 with pytest.raises(error_class):
176184 core_with_mock_sdk.create_full_scan([str(manifest)], MagicMock())
@@ -241,25 +249,37 @@ def test_temp_br_file_cleaned_after_exhausted_retries(
241249 assert not compressed.exists()
242250
243251
244- def test_transient_classifier():
245- assert _is_transient_full_scan_upload_error(APIBadGateway())
246- assert _is_transient_full_scan_upload_error(APIConnectionError())
247- assert _is_transient_full_scan_upload_error(APITimeout())
248- assert _is_transient_full_scan_upload_error(_catch_all_failure(408))
249- assert _is_transient_full_scan_upload_error(_catch_all_failure(503))
250- assert _is_transient_full_scan_upload_error(_catch_all_failure(504))
251-
252- assert not _is_transient_full_scan_upload_error(_catch_all_failure(400))
253- assert not _is_transient_full_scan_upload_error(_catch_all_failure(500))
254- assert not _is_transient_full_scan_upload_error(
255- APIFailure()
256- ) # wrapped unexpected error
257- assert not _is_transient_full_scan_upload_error(APIAccessDenied("denied"))
258- assert not _is_transient_full_scan_upload_error(APIResourceNotFound())
259- # Subclasses never match the catch-all branch, even with a retryable-looking message.
260- assert not _is_transient_full_scan_upload_error(
261- APIAccessDenied("original_status_code:503")
262- )
263- assert not _is_transient_full_scan_upload_error(
264- ValueError("original_status_code:503")
265- )
252+ class _StubFailure(APIFailure):
253+ """An APIFailure whose transience is fixed, regardless of class or status code."""
254+
255+ def __init__(self, transient: bool):
256+ super().__init__("stub failure")
257+ self._transient = transient
258+
259+ def is_transient_error(self) -> bool:
260+ return self._transient
261+
262+
263+ @pytest.mark.parametrize("transient,expected_calls", [(True, 2), (False, 1)])
264+ def test_retry_decision_delegates_to_sdk_classification(
265+ core_with_mock_sdk, tmp_path, no_sleep, transient, expected_calls
266+ ):
267+ # The CLI encodes no knowledge of the SDK's exception hierarchy or status codes:
268+ # the retry decision is exactly APIFailure.is_transient_error(). (The transient /
269+ # non-transient truth table itself is tested in the SDK, next to the code that
270+ # raises the exceptions.)
271+ manifest = tmp_path / "package.json"
272+ manifest.write_text("{}")
273+ core_with_mock_sdk.sdk.fullscans.post.side_effect = [
274+ _StubFailure(transient),
275+ _success_response(),
276+ ]
277+
278+ if transient:
279+ full_scan = core_with_mock_sdk.create_full_scan([str(manifest)], MagicMock())
280+ assert full_scan.id == "scan-1"
281+ else:
282+ with pytest.raises(_StubFailure):
283+ core_with_mock_sdk.create_full_scan([str(manifest)], MagicMock())
284+
285+ assert core_with_mock_sdk.sdk.fullscans.post.call_count == expected_calls
0 commit comments