Skip to content

Commit e646d56

Browse files
leliaclaude
andcommitted
Poll diff-scans endpoints for scan comparison instead of streaming
The scan comparison (fullscans.stream_diff) held a single HTTP connection open, fully idle, while the API computed the diff. Network middleboxes with TCP idle timeouts - notably Azure NAT gateways, which default to 4 minutes - kill that connection with a RST, surfacing as intermittent "Connection reset by peer" / blank "API Error:" failures on the final comparison step of long scans (CE-354). The comparison now creates a diff-scan resource (POST /orgs/{org}/diff-scans/from-ids) and polls GET /orgs/{org}/diff-scans/{id}?cached=true with short bounded requests: 202 while the diff is computing, 200 with the result once ready. No request is ever idle long enough to be reaped, and the poll interval backs off 5s -> 30s to stay quota-friendly (each poll costs 1 quota unit). Transient poll failures retry; a 30-minute backstop guards against a diff scan that never completes. Any failure of the new flow (e.g. org tokens missing the diff-scans:create / diff-scans:list / full-scans:list scopes) logs a warning and falls back to the legacy streaming comparison, so the change is transparent to existing users. Requires socketdev>=3.4.0 for diffscans.get query-param/202 support. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ebdc3e4 commit e646d56

7 files changed

Lines changed: 316 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# Changelog
22

3+
## 2.6.0
4+
5+
### Changed: scan comparison now polls the diff-scans endpoints
6+
7+
- Diff mode no longer holds a single idle HTTP connection open while the API
8+
computes the scan comparison. The CLI now creates a diff-scan resource
9+
(`POST /orgs/{org}/diff-scans/from-ids`) and polls
10+
`GET /orgs/{org}/diff-scans/{id}?cached=true` with short, bounded requests
11+
until the comparison is ready (HTTP 200 instead of 202). This fixes
12+
intermittent `Connection reset by peer` failures on the final comparison
13+
step when scans take several minutes to compare and network middleboxes
14+
(e.g. Azure NAT gateways, which default to a 4-minute TCP idle timeout)
15+
reap the idle connection (CE-354).
16+
- The change is transparent: no flags or workflow changes are needed. If the
17+
org API token is missing the `diff-scans:create`, `diff-scans:list` or
18+
`full-scans:list` scopes — or the new flow fails for any other reason — the
19+
CLI logs a warning and falls back to the legacy streaming comparison.
20+
- Requires `socketdev>=3.4.0`.
21+
322
## 2.5.9
423

524
### Changed: bump pinned @coana-tech/cli to 15.10.3

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
66

77
[project]
88
name = "socketsecurity"
9-
version = "2.5.9"
9+
version = "2.6.0"
1010
requires-python = ">= 3.11"
1111
license = {"file" = "LICENSE"}
1212
dependencies = [
@@ -16,7 +16,7 @@ dependencies = [
1616
'GitPython',
1717
'packaging',
1818
'python-dotenv',
19-
"socketdev>=3.3.0,<4.0.0",
19+
"socketdev>=3.4.0,<4.0.0",
2020
"bs4>=0.0.2",
2121
"markdown>=3.10",
2222
"brotli>=1.0.9; platform_python_implementation == 'CPython'",

socketsecurity/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__author__ = 'socket.dev'
2-
__version__ = '2.5.9'
2+
__version__ = '2.6.0'
33
USER_AGENT = f'SocketPythonCLI/{__version__}'

socketsecurity/core/__init__.py

Lines changed: 151 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from socketsecurity.config import CliConfig
1616
from socketdev import socketdev
1717
from socketdev.exceptions import APIFailure
18-
from socketdev.fullscans import FullScanParams, SocketArtifact
18+
from socketdev.fullscans import DiffArtifacts, FullScanParams, SocketArtifact
1919
from socketdev.org import Organization
2020
from socketdev.repos import RepositoryInfo
2121
import copy
@@ -92,6 +92,25 @@
9292
FULL_SCAN_UPLOAD_MAX_ATTEMPTS = len(FULL_SCAN_UPLOAD_BACKOFF_SCHEDULE_SECONDS)
9393
FULL_SCAN_UPLOAD_BACKOFF_JITTER_SECONDS = 2.0
9494

95+
# Diff-scan polling policy. The legacy scan comparison (fullscans.stream_diff) holds a
96+
# single HTTP connection open, fully idle, while the backend computes the diff; network
97+
# middleboxes with TCP idle timeouts (notably Azure NAT gateways, which default to
98+
# 4 minutes) kill that connection with a RST, surfacing as an intermittent
99+
# ConnectionResetError on large scans (CE-354). The diff-scans flow instead creates a
100+
# diff-scan resource and polls its cached endpoint with short bounded requests: the API
101+
# answers 202 while the comparison is still computing and 200 with the result once it is
102+
# ready, so no connection is ever idle long enough to be reaped.
103+
#
104+
# Each poll consumes 1 unit of API quota, so the interval backs off toward
105+
# DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS to stay quota-friendly on comparisons that take
106+
# minutes to compute. The timeout is a backstop against a diff scan that never
107+
# completes; on expiry (or any other failure of this flow) the caller falls back to the
108+
# legacy streaming comparison rather than failing the scan outright.
109+
DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS = 5.0
110+
DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS = 30.0
111+
DIFF_SCAN_POLL_BACKOFF_MULTIPLIER = 1.5
112+
DIFF_SCAN_POLL_TIMEOUT_SECONDS = 30 * 60.0
113+
95114

96115
def _humanize_alert_type(alert_type: str) -> str:
97116
"""Convert a camelCase/PascalCase alert type into a Title-Cased label.
@@ -1303,6 +1322,93 @@ def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: in
13031322

13041323
return packages
13051324

1325+
def get_diff_scan_artifacts(
1326+
self,
1327+
head_full_scan_id: str,
1328+
new_full_scan_id: str,
1329+
include_license_details: bool = False
1330+
) -> DiffArtifacts:
1331+
"""Compare two full scans via the diff-scans endpoints, polling for the result.
1332+
1333+
Creates a diff-scan resource from the two full scan IDs, then polls
1334+
``GET /orgs/{org}/diff-scans/{id}?cached=true`` until the API returns the
1335+
computed comparison (200) instead of a processing status (202). Unlike the
1336+
legacy ``fullscans.stream_diff`` call, no request is ever left idle while
1337+
the backend computes, so the comparison survives network idle timeouts
1338+
(CE-354). See the DIFF_SCAN_POLL_* constants for the polling policy.
1339+
1340+
Requires an org token with the ``diff-scans:create``, ``diff-scans:list``
1341+
and ``full-scans:list`` scopes; callers are expected to catch failures and
1342+
fall back to the legacy streaming comparison.
1343+
1344+
Args:
1345+
head_full_scan_id: The before/base full scan ID
1346+
new_full_scan_id: The after/head full scan ID
1347+
include_license_details: Whether to keep embedded per-package license
1348+
details in the response (see get_added_and_removed_packages for
1349+
why this defaults to False)
1350+
1351+
Returns:
1352+
DiffArtifacts with the added/removed/unchanged/replaced/updated lists
1353+
"""
1354+
create_params = {
1355+
"before": head_full_scan_id,
1356+
"after": new_full_scan_id,
1357+
"description": f"Socket Security CLI v{__version__} scan comparison",
1358+
# A rerun against the same pair of scans returns the existing diff
1359+
# scan instead of failing with a 409.
1360+
"on_duplicate": "redirect",
1361+
}
1362+
result = self.sdk.diffscans.create_from_ids(self.config.org_slug, create_params)
1363+
diff_scan = result.get("diff_scan") or {}
1364+
diff_scan_id = diff_scan.get("id")
1365+
if not diff_scan_id:
1366+
raise Exception(f"Error creating diff scan: unexpected response: {str(result)[:500]}")
1367+
# An on_duplicate redirect can land on an already-computed diff scan, in
1368+
# which case the create response already carries the artifacts.
1369+
artifacts_dict = diff_scan.get("artifacts")
1370+
1371+
poll_params = {
1372+
"cached": "true",
1373+
"omit_license_details": "false" if include_license_details else "true",
1374+
}
1375+
deadline = time.monotonic() + DIFF_SCAN_POLL_TIMEOUT_SECONDS
1376+
interval = DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS
1377+
while artifacts_dict is None:
1378+
try:
1379+
response = self.sdk.diffscans.get(self.config.org_slug, diff_scan_id, params=poll_params)
1380+
except APIFailure as error:
1381+
if not error.is_transient_error():
1382+
raise
1383+
# A dropped/timed-out poll is retryable: the diff scan keeps
1384+
# computing server-side regardless of what happens to any one poll.
1385+
log.warning(
1386+
f"Transient error polling diff scan {diff_scan_id} "
1387+
f"({type(error).__name__}), retrying in {interval:.0f}s"
1388+
)
1389+
response = {"status": "processing"}
1390+
if response.get("status") != "processing":
1391+
scan = response.get("diff_scan") or {}
1392+
if scan.get("artifacts") is None:
1393+
raise Exception(
1394+
f"Error fetching diff scan {diff_scan_id}: unexpected response: {str(response)[:500]}"
1395+
)
1396+
artifacts_dict = scan["artifacts"]
1397+
break
1398+
if time.monotonic() >= deadline:
1399+
raise Exception(
1400+
f"Timed out waiting for diff scan {diff_scan_id} after "
1401+
f"{DIFF_SCAN_POLL_TIMEOUT_SECONDS:.0f} seconds"
1402+
)
1403+
log.debug(f"Diff scan {diff_scan_id} still processing, polling again in {interval:.0f}s")
1404+
time.sleep(interval)
1405+
interval = min(interval * DIFF_SCAN_POLL_BACKOFF_MULTIPLIER, DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS)
1406+
1407+
return DiffArtifacts.from_dict({
1408+
key: artifacts_dict.get(key) or []
1409+
for key in ("added", "removed", "unchanged", "replaced", "updated")
1410+
})
1411+
13061412
def get_added_and_removed_packages(
13071413
self,
13081414
head_full_scan_id: str,
@@ -1343,39 +1449,56 @@ def get_added_and_removed_packages(
13431449

13441450
log.info(f"Comparing scans - Head scan ID: {head_full_scan_id}, New scan ID: {new_full_scan_id}")
13451451
diff_start = time.time()
1452+
diff_artifacts = None
13461453
try:
1347-
diff_report = (
1348-
self.sdk.fullscans.stream_diff(
1349-
self.config.org_slug,
1350-
head_full_scan_id,
1351-
new_full_scan_id,
1352-
use_types=True,
1353-
include_license_details=str(include_license_details).lower()
1354-
).data
1454+
diff_artifacts = self.get_diff_scan_artifacts(
1455+
head_full_scan_id,
1456+
new_full_scan_id,
1457+
include_license_details=include_license_details
13551458
)
1356-
except APIFailure as e:
1357-
log.error(f"API Error: {e}")
1358-
if self.cli_config and self.cli_config.disable_blocking:
1359-
sys.exit(0)
1360-
sys.exit(1)
1361-
except Exception as e:
1362-
import traceback
1363-
log.error(f"Error getting diff report: {str(e)}")
1364-
log.error(f"Stack trace:\n{traceback.format_exc()}")
1365-
raise
1459+
except Exception as error:
1460+
# SDK error messages can span many lines (path + response headers); the
1461+
# first line carries the status, which is all the warning needs.
1462+
error_summary = str(error).strip().splitlines()[0] if str(error).strip() else ""
1463+
log.warning(
1464+
f"Diff scan comparison failed with {type(error).__name__}({error_summary}), "
1465+
"falling back to the streaming scan comparison"
1466+
)
1467+
1468+
if diff_artifacts is None:
1469+
try:
1470+
diff_artifacts = (
1471+
self.sdk.fullscans.stream_diff(
1472+
self.config.org_slug,
1473+
head_full_scan_id,
1474+
new_full_scan_id,
1475+
use_types=True,
1476+
include_license_details=str(include_license_details).lower()
1477+
).data.artifacts
1478+
)
1479+
except APIFailure as e:
1480+
log.error(f"API Error: {e}")
1481+
if self.cli_config and self.cli_config.disable_blocking:
1482+
sys.exit(0)
1483+
sys.exit(1)
1484+
except Exception as e:
1485+
import traceback
1486+
log.error(f"Error getting diff report: {str(e)}")
1487+
log.error(f"Stack trace:\n{traceback.format_exc()}")
1488+
raise
13661489

13671490
diff_end = time.time()
13681491
log.info(f"Diff Report Gathered in {diff_end - diff_start:.2f} seconds")
13691492
log.info("Diff report artifact counts:")
1370-
log.info(f"Added: {len(diff_report.artifacts.added)}")
1371-
log.info(f"Removed: {len(diff_report.artifacts.removed)}")
1372-
log.info(f"Unchanged: {len(diff_report.artifacts.unchanged)}")
1373-
log.info(f"Replaced: {len(diff_report.artifacts.replaced)}")
1374-
log.info(f"Updated: {len(diff_report.artifacts.updated)}")
1375-
1376-
added_artifacts = diff_report.artifacts.added + diff_report.artifacts.updated
1377-
removed_artifacts = diff_report.artifacts.removed + diff_report.artifacts.replaced
1378-
unchanged_artifacts = diff_report.artifacts.unchanged
1493+
log.info(f"Added: {len(diff_artifacts.added)}")
1494+
log.info(f"Removed: {len(diff_artifacts.removed)}")
1495+
log.info(f"Unchanged: {len(diff_artifacts.unchanged)}")
1496+
log.info(f"Replaced: {len(diff_artifacts.replaced)}")
1497+
log.info(f"Updated: {len(diff_artifacts.updated)}")
1498+
1499+
added_artifacts = diff_artifacts.added + diff_artifacts.updated
1500+
removed_artifacts = diff_artifacts.removed + diff_artifacts.replaced
1501+
unchanged_artifacts = diff_artifacts.unchanged
13791502

13801503
added_packages: Dict[str, Package] = {}
13811504
removed_packages: Dict[str, Package] = {}

tests/core/conftest.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,22 @@ def stream_diff_response(data_dir, load_json):
8787
})
8888

8989

90+
@pytest.fixture
91+
def diff_scan_get_response(data_dir, load_json):
92+
"""GET /orgs/{org}/diff-scans/{id} response built from the stream_diff fixture.
93+
94+
The diff-scans endpoint returns the same artifact shape as the legacy
95+
streaming diff, wrapped in a diff_scan object.
96+
"""
97+
json_data = load_json(data_dir / "fullscans" / "diff" / "stream_diff.json")
98+
return {
99+
"diff_scan": {
100+
"id": "diff-scan-123",
101+
"artifacts": json_data["data"]["artifacts"],
102+
}
103+
}
104+
105+
90106

91107

92108

@@ -138,6 +154,7 @@ def mock_sdk_with_responses(
138154
new_scan_metadata,
139155
new_scan_stream,
140156
stream_diff_response,
157+
diff_scan_get_response,
141158
create_full_scan_response,
142159
):
143160
sdk = mock_socket_sdk.return_value
@@ -173,4 +190,8 @@ def mock_sdk_with_responses(
173190
lambda org_slug, head_id, new_id, **kwargs: stream_diff_response
174191
)
175192

193+
# Diff-scans endpoints (primary scan-comparison path)
194+
sdk.diffscans.create_from_ids.return_value = {"diff_scan": {"id": "diff-scan-123"}}
195+
sdk.diffscans.get.return_value = diff_scan_get_response
196+
176197
return sdk

0 commit comments

Comments
 (0)