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:\n cf-ray: abc123"
55+ f"Headers:\n cf-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