Fix intermittent connection resets on scan comparison by polling the diff-scans endpoints - #284
Open
lelia wants to merge 3 commits into
Open
Fix intermittent connection resets on scan comparison by polling the diff-scans endpoints#284lelia wants to merge 3 commits into
lelia wants to merge 3 commits into
Conversation
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>
lelia
marked this pull request as ready for review
August 5, 2026 04:11
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Done
Or push these changes by commenting:
@cursor push 2dba87dda6
Preview (2dba87dda6)
diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py
--- a/socketsecurity/core/__init__.py
+++ b/socketsecurity/core/__init__.py
@@ -1337,6 +1337,11 @@ def get_diff_scan_artifacts(
the backend computes, so the comparison survives network idle timeouts
(CE-354). See the DIFF_SCAN_POLL_* constants for the polling policy.
+ When ``include_license_details`` is False (the default), a final fetch
+ without ``cached`` requests ``omit_license_details=true``. The API
+ ignores that flag on cached responses, so the lean payload has to come
+ from a separate non-cached get (CE-224).
+
Requires an org token with the ``diff-scans:create``, ``diff-scans:list``
and ``full-scans:list`` scopes; callers are expected to catch failures and
fall back to the legacy streaming comparison.
@@ -1337,6 +1337,11 @@ def get_diff_scan_artifacts(
the backend computes, so the comparison survives network idle timeouts
(CE-354). See the DIFF_SCAN_POLL_* constants for the polling policy.
+ When ``include_license_details`` is False (the default), a final fetch
+ without ``cached`` requests ``omit_license_details=true``. The API
+ ignores that flag on cached responses, so the lean payload has to come
+ from a separate non-cached get (CE-224).
+
Requires an org token with the ``diff-scans:create``, ``diff-scans:list``
and ``full-scans:list`` scopes; callers are expected to catch failures and
fall back to the legacy streaming comparison.
@@ -1368,10 +1373,16 @@ def get_diff_scan_artifacts(
# which case the create response already carries the artifacts.
artifacts_dict = diff_scan.get("artifacts")
- poll_params = {
- "cached": "true",
- "omit_license_details": "false" if include_license_details else "true",
- }
+ # Poll with cached=true for short bounded 202/200 responses (CE-354).
+ # The API ignores omit_license_details whenever cached=true — cached
+ # payloads always embed full license data — so readiness polling never
+ # requests it. When license details should be omitted (the default;
+ # CE-224), the lean payload is fetched separately below without cached.
+ poll_params = {"cached": "true"}
+ if not include_license_details:
+ # Keep the readiness response small so the ignored omit doesn't
+ # reintroduce large-response truncation while we wait for 200.
+ poll_params["omit_unchanged"] = "true"
deadline = time.monotonic() + DIFF_SCAN_POLL_TIMEOUT_SECONDS
interval = DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS
while artifacts_dict is None:
@@ -1368,10 +1373,16 @@ def get_diff_scan_artifacts(
# which case the create response already carries the artifacts.
artifacts_dict = diff_scan.get("artifacts")
- poll_params = {
- "cached": "true",
- "omit_license_details": "false" if include_license_details else "true",
- }
+ # Poll with cached=true for short bounded 202/200 responses (CE-354).
+ # The API ignores omit_license_details whenever cached=true — cached
+ # payloads always embed full license data — so readiness polling never
+ # requests it. When license details should be omitted (the default;
+ # CE-224), the lean payload is fetched separately below without cached.
+ poll_params = {"cached": "true"}
+ if not include_license_details:
+ # Keep the readiness response small so the ignored omit doesn't
+ # reintroduce large-response truncation while we wait for 200.
+ poll_params["omit_unchanged"] = "true"
deadline = time.monotonic() + DIFF_SCAN_POLL_TIMEOUT_SECONDS
interval = DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS
while artifacts_dict is None:
@@ -1404,6 +1415,21 @@ def get_diff_scan_artifacts(
time.sleep(interval)
interval = min(interval * DIFF_SCAN_POLL_BACKOFF_MULTIPLIER, DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS)
+ # Cached results always include license details. Re-fetch once without
+ # cached so omit_license_details is honored and the diff stays lean.
+ if not include_license_details:
+ response = self.sdk.diffscans.get(
+ self.config.org_slug,
+ diff_scan_id,
+ params={"omit_license_details": "true"},
+ )
+ scan = response.get("diff_scan") or {}
+ if scan.get("artifacts") is None:
+ raise Exception(
+ f"Error fetching diff scan {diff_scan_id}: unexpected response: {str(response)[:500]}"
+ )
+ artifacts_dict = scan["artifacts"]
+
return DiffArtifacts.from_dict({
key: artifacts_dict.get(key) or []
for key in ("added", "removed", "unchanged", "replaced", "updated")
@@ -1404,6 +1415,21 @@ def get_diff_scan_artifacts(
time.sleep(interval)
interval = min(interval * DIFF_SCAN_POLL_BACKOFF_MULTIPLIER, DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS)
+ # Cached results always include license details. Re-fetch once without
+ # cached so omit_license_details is honored and the diff stays lean.
+ if not include_license_details:
+ response = self.sdk.diffscans.get(
+ self.config.org_slug,
+ diff_scan_id,
+ params={"omit_license_details": "true"},
+ )
+ scan = response.get("diff_scan") or {}
+ if scan.get("artifacts") is None:
+ raise Exception(
+ f"Error fetching diff scan {diff_scan_id}: unexpected response: {str(response)[:500]}"
+ )
+ artifacts_dict = scan["artifacts"]
+
return DiffArtifacts.from_dict({
key: artifacts_dict.get(key) or []
for key in ("added", "removed", "unchanged", "replaced", "updated")
diff --git a/tests/core/test_diff_scan_polling.py b/tests/core/test_diff_scan_polling.py
--- a/tests/core/test_diff_scan_polling.py
+++ b/tests/core/test_diff_scan_polling.py
@@ -26,11 +26,14 @@ def no_sleep(mocker):
def test_polls_until_diff_scan_ready(core, diff_scan_get_response, no_sleep):
"""202 processing responses are polled through until the 200 result arrives."""
processing = {"status": "processing", "id": "diff-scan-123"}
- core.sdk.diffscans.get.side_effect = [processing, processing, diff_scan_get_response]
+ # Final cached poll + lean omit_license_details re-fetch.
+ core.sdk.diffscans.get.side_effect = [
+ processing, processing, diff_scan_get_response, diff_scan_get_response
+ ]
artifacts = core.get_diff_scan_artifacts("head", "new")
- assert core.sdk.diffscans.get.call_count == 3
+ assert core.sdk.diffscans.get.call_count == 4
assert no_sleep.call_count == 2 # slept between polls, never during them
assert len(artifacts.added) > 0
@@ -26,11 +26,14 @@ def no_sleep(mocker):
def test_polls_until_diff_scan_ready(core, diff_scan_get_response, no_sleep):
"""202 processing responses are polled through until the 200 result arrives."""
processing = {"status": "processing", "id": "diff-scan-123"}
- core.sdk.diffscans.get.side_effect = [processing, processing, diff_scan_get_response]
+ # Final cached poll + lean omit_license_details re-fetch.
+ core.sdk.diffscans.get.side_effect = [
+ processing, processing, diff_scan_get_response, diff_scan_get_response
+ ]
artifacts = core.get_diff_scan_artifacts("head", "new")
- assert core.sdk.diffscans.get.call_count == 3
+ assert core.sdk.diffscans.get.call_count == 4
assert no_sleep.call_count == 2 # slept between polls, never during them
assert len(artifacts.added) > 0
@@ -40,7 +43,9 @@ def test_poll_interval_backs_off(core, diff_scan_get_response, no_sleep, monkeyp
monkeypatch.setattr(core_module, "DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS", 4.0)
monkeypatch.setattr(core_module, "DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS", 10.0)
processing = {"status": "processing", "id": "diff-scan-123"}
- core.sdk.diffscans.get.side_effect = [processing] * 4 + [diff_scan_get_response]
+ core.sdk.diffscans.get.side_effect = (
+ [processing] * 4 + [diff_scan_get_response, diff_scan_get_response]
+ )
core.get_diff_scan_artifacts("head", "new")
@@ -40,7 +43,9 @@ def test_poll_interval_backs_off(core, diff_scan_get_response, no_sleep, monkeyp
monkeypatch.setattr(core_module, "DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS", 4.0)
monkeypatch.setattr(core_module, "DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS", 10.0)
processing = {"status": "processing", "id": "diff-scan-123"}
- core.sdk.diffscans.get.side_effect = [processing] * 4 + [diff_scan_get_response]
+ core.sdk.diffscans.get.side_effect = (
+ [processing] * 4 + [diff_scan_get_response, diff_scan_get_response]
+ )
core.get_diff_scan_artifacts("head", "new")
@@ -50,11 +55,13 @@ def test_poll_interval_backs_off(core, diff_scan_get_response, no_sleep, monkeyp
def test_transient_poll_error_is_retried(core, diff_scan_get_response, no_sleep):
"""A dropped poll doesn't abandon the flow - the diff keeps computing server-side."""
- core.sdk.diffscans.get.side_effect = [APIConnectionError("reset"), diff_scan_get_response]
+ core.sdk.diffscans.get.side_effect = [
+ APIConnectionError("reset"), diff_scan_get_response, diff_scan_get_response
+ ]
artifacts = core.get_diff_scan_artifacts("head", "new")
- assert core.sdk.diffscans.get.call_count == 2
+ assert core.sdk.diffscans.get.call_count == 3
assert len(artifacts.added) > 0
@@ -50,11 +55,13 @@ def test_poll_interval_backs_off(core, diff_scan_get_response, no_sleep, monkeyp
def test_transient_poll_error_is_retried(core, diff_scan_get_response, no_sleep):
"""A dropped poll doesn't abandon the flow - the diff keeps computing server-side."""
- core.sdk.diffscans.get.side_effect = [APIConnectionError("reset"), diff_scan_get_response]
+ core.sdk.diffscans.get.side_effect = [
+ APIConnectionError("reset"), diff_scan_get_response, diff_scan_get_response
+ ]
artifacts = core.get_diff_scan_artifacts("head", "new")
- assert core.sdk.diffscans.get.call_count == 2
+ assert core.sdk.diffscans.get.call_count == 3
assert len(artifacts.added) > 0
@@ -76,15 +83,45 @@ def test_poll_timeout_raises(core, no_sleep, monkeypatch):
def test_duplicate_redirect_uses_embedded_artifacts(core, diff_scan_get_response):
- """An on_duplicate redirect can return the computed diff scan straight away."""
+ """An on_duplicate redirect skips readiness polling; lean re-fetch still runs."""
core.sdk.diffscans.create_from_ids.return_value = diff_scan_get_response
artifacts = core.get_diff_scan_artifacts("head", "new")
- core.sdk.diffscans.get.assert_not_called()
+ # Create already carried artifacts, so cached readiness polling is skipped,
+ # but omit_license_details still needs a non-cached get (cached ignores it).
+ core.sdk.diffscans.get.assert_called_once_with(
+ core.config.org_slug,
+ "diff-scan-123",
+ params={"omit_license_details": "true"},
+ )
assert len(artifacts.added) > 0
+def test_lean_refetch_omits_license_details_without_cached(
+ core, diff_scan_get_response, no_sleep
+):
+ """omit_license_details is fetched without cached=true (API ignores it otherwise)."""
+ processing = {"status": "processing", "id": "diff-scan-123"}
+ core.sdk.diffscans.get.side_effect = [
+ processing, diff_scan_get_response, diff_scan_get_response
+ ]
+
+ core.get_diff_scan_artifacts("head", "new")
+
+ assert core.sdk.diffscans.get.call_args_list[0].kwargs["params"] == {
+ "cached": "true",
+ "omit_unchanged": "true",
+ }
+ assert core.sdk.diffscans.get.call_args_list[1].kwargs["params"] == {
+ "cached": "true",
+ "omit_unchanged": "true",
+ }
+ assert core.sdk.diffscans.get.call_args_list[2].kwargs["params"] == {
+ "omit_license_details": "true",
+ }
+
+
def test_fallback_to_streaming_diff_on_failure(core):
"""If the diff-scans flow fails (e.g. token missing the diff-scans scopes),
the comparison falls back to the legacy streaming diff transparently."""
@@ -76,15 +83,45 @@ def test_poll_timeout_raises(core, no_sleep, monkeypatch):
def test_duplicate_redirect_uses_embedded_artifacts(core, diff_scan_get_response):
- """An on_duplicate redirect can return the computed diff scan straight away."""
+ """An on_duplicate redirect skips readiness polling; lean re-fetch still runs."""
core.sdk.diffscans.create_from_ids.return_value = diff_scan_get_response
artifacts = core.get_diff_scan_artifacts("head", "new")
- core.sdk.diffscans.get.assert_not_called()
+ # Create already carried artifacts, so cached readiness polling is skipped,
+ # but omit_license_details still needs a non-cached get (cached ignores it).
+ core.sdk.diffscans.get.assert_called_once_with(
+ core.config.org_slug,
+ "diff-scan-123",
+ params={"omit_license_details": "true"},
+ )
assert len(artifacts.added) > 0
+def test_lean_refetch_omits_license_details_without_cached(
+ core, diff_scan_get_response, no_sleep
+):
+ """omit_license_details is fetched without cached=true (API ignores it otherwise)."""
+ processing = {"status": "processing", "id": "diff-scan-123"}
+ core.sdk.diffscans.get.side_effect = [
+ processing, diff_scan_get_response, diff_scan_get_response
+ ]
+
+ core.get_diff_scan_artifacts("head", "new")
+
+ assert core.sdk.diffscans.get.call_args_list[0].kwargs["params"] == {
+ "cached": "true",
+ "omit_unchanged": "true",
+ }
+ assert core.sdk.diffscans.get.call_args_list[1].kwargs["params"] == {
+ "cached": "true",
+ "omit_unchanged": "true",
+ }
+ assert core.sdk.diffscans.get.call_args_list[2].kwargs["params"] == {
+ "omit_license_details": "true",
+ }
+
+
def test_fallback_to_streaming_diff_on_failure(core):
"""If the diff-scans flow fails (e.g. token missing the diff-scans scopes),
the comparison falls back to the legacy streaming diff transparently."""
diff --git a/tests/core/test_sdk_methods.py b/tests/core/test_sdk_methods.py
--- a/tests/core/test_sdk_methods.py
+++ b/tests/core/test_sdk_methods.py
@@ -239,12 +239,15 @@ def test_get_added_and_removed_packages(core):
# include_license_details defaults to False: the diff path never consumes
# embedded license data (license artifacts come from the PURL endpoint), so
# requesting it only bloats the response and risks the truncation
- # crash on large repos.
- core.sdk.diffscans.get.assert_called_once_with(
- core.config.org_slug,
- "diff-scan-123",
- params={"cached": "true", "omit_license_details": "true"},
- )
+ # crash on large repos. cached=true ignores omit_license_details, so the
+ # poll checks readiness (optionally omitting unchanged to stay small) and
+ # a separate non-cached get fetches the lean payload.
+ get_calls = core.sdk.diffscans.get.call_args_list
+ assert len(get_calls) == 2
+ assert get_calls[0].args == (core.config.org_slug, "diff-scan-123")
+ assert get_calls[0].kwargs["params"] == {"cached": "true", "omit_unchanged": "true"}
+ assert get_calls[1].args == (core.config.org_slug, "diff-scan-123")
+ assert get_calls[1].kwargs["params"] == {"omit_license_details": "true"}
core.sdk.fullscans.stream_diff.assert_not_called()
# Verify the results
@@ -239,12 +239,15 @@ def test_get_added_and_removed_packages(core):
# include_license_details defaults to False: the diff path never consumes
# embedded license data (license artifacts come from the PURL endpoint), so
# requesting it only bloats the response and risks the truncation
- # crash on large repos.
- core.sdk.diffscans.get.assert_called_once_with(
- core.config.org_slug,
- "diff-scan-123",
- params={"cached": "true", "omit_license_details": "true"},
- )
+ # crash on large repos. cached=true ignores omit_license_details, so the
+ # poll checks readiness (optionally omitting unchanged to stay small) and
+ # a separate non-cached get fetches the lean payload.
+ get_calls = core.sdk.diffscans.get.call_args_list
+ assert len(get_calls) == 2
+ assert get_calls[0].args == (core.config.org_slug, "diff-scan-123")
+ assert get_calls[0].kwargs["params"] == {"cached": "true", "omit_unchanged": "true"}
+ assert get_calls[1].args == (core.config.org_slug, "diff-scan-123")
+ assert get_calls[1].kwargs["params"] == {"omit_license_details": "true"}
core.sdk.fullscans.stream_diff.assert_not_called()
# Verify the results
@@ -263,10 +266,12 @@ def test_get_added_and_removed_packages_license_override(core):
"""The include_license_details override seam still works when explicitly requested."""
core.get_added_and_removed_packages("head", "new", include_license_details=True)
+ # When license details are wanted, the cached poll response is used directly
+ # — no lean re-fetch, and omit_license_details is not sent.
core.sdk.diffscans.get.assert_called_once_with(
core.config.org_slug,
"diff-scan-123",
- params={"cached": "true", "omit_license_details": "false"},
+ params={"cached": "true"},
)
def test_empty_alerts_preserved(core):
@@ -263,10 +266,12 @@ def test_get_added_and_removed_packages_license_override(core):
"""The include_license_details override seam still works when explicitly requested."""
core.get_added_and_removed_packages("head", "new", include_license_details=True)
+ # When license details are wanted, the cached poll response is used directly
+ # — no lean re-fetch, and omit_license_details is not sent.
core.sdk.diffscans.get.assert_called_once_with(
core.config.org_slug,
"diff-scan-123",
- params={"cached": "true", "omit_license_details": "false"},
+ params={"cached": "true"},
)
def test_empty_alerts_preserved(core):You can send follow-ups to the cloud agent here.
The API ignores omit_license_details when cached=true - cached diff-scan results always embed license details - so sending the param suggested a lean-response guarantee the polling path doesn't have. Document the caveat instead: if the heavier payload ever gets truncated on a huge dependency tree, JSON parsing fails and the caller already falls back to the legacy streaming comparison, which still requests the lean payload. include_license_details now only governs that fallback call. Flagged by Cursor Bugbot on #284. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
Author
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 58a2fb4. Configure here.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Diff-mode scans on self-hosted CI runners intermittently fail on the final comparison step with
Connection error after 261.68 seconds: ('Connection aborted.', ConnectionResetError(104, 'Connection reset by peer'))followed by a blankAPI Error:, even though the full scan itself succeeded and the results are on the dashboard.Root Cause
The scan comparison used
fullscans.stream_diff, which sends one request and then holds the connection open — completely idle — while the API computes the diff. When the comparison takes longer than a network middlebox's TCP idle timeout (Azure NAT gateways default to 4 minutes; the reported resets fired at ~262s on runners egressing through Azure), the middlebox reaps the "zombie" connection and sends a RST. Our API logs showrequest aborted(client-side termination) with no response ever written. Intermittency tracks diff computation time: fast diffs finish before the idle timer, slow ones (no baseline / large dependency trees) don't.Fix
Per dougbot's suggestion, the comparison now uses the diff-scans endpoints with polling instead of a held-open connection:
POST /orgs/{org}/diff-scans/from-idscreates a diff-scan resource for the two full scans (returns metadata immediately;on_duplicate=redirectmakes reruns of the same pair benign).GET /orgs/{org}/diff-scans/{id}?cached=true— the API answers 202 while the diff is computing and 200 with the artifacts once ready. Every request is short and bounded, so nothing is ever idle long enough to be reaped.Details:
APIFailure.is_transient_error()) retry within the loop — the diff keeps computing server-side regardless.stream_diffpath, so tokens missing the newly-required scopes (diff-scans:create,diff-scans:list,full-scans:list) keep working exactly as today. No flags, no behavior change otherwise — full-scan creation, head-scan management (including the new--base-scan-id/--base-commit-shabaseline overrides from 2.5.0), and reachability finalize are untouched.f31cfa7): the API ignoresomit_license_detailson cached reads, so cached diff-scan results always embed license details — there is no lean-response option here, unlikestream_diffwithinclude_license_details=false(the CE-224 mitigation). If that heavier payload ever gets truncated on a huge dependency tree, JSON parsing fails and the existing fallback kicks in, which still requests the lean streaming payload. Worth considering server-side: honoringomit_license_detailsfor cached reads would restore the lean option on this endpoint.Dependencies / rollout:
socketdev>=3.4.0— companion SDK PR: Add cached diff-scan polling support to DiffScans.get socket-sdk-python#99 (adds query-param + 202 support todiffscans.get). The SDK must be released to PyPI first;uv.lockneeds regenerating after that (lockfile intentionally not updated here since 3.4.0 isn't published yet).Testing: new
tests/core/test_diff_scan_polling.pycovers poll-until-ready, backoff schedule, transient-error retry, non-transient propagation, timeout backstop, duplicate-redirect shortcut, and the streaming fallback. Full suite after rebasing onto v2.5.8: 427 passed, 2 skipped (run against the local SDK build).Public Changelog
Diff-mode scan comparison no longer holds an idle HTTP connection open while the API computes the diff. The CLI now polls the diff-scans endpoints with short bounded requests, fixing intermittent "Connection reset by peer" failures on the final comparison step behind network gear with TCP idle timeouts (e.g. Azure NAT gateways). The change is transparent; if the API token lacks the diff-scans scopes the CLI falls back to the previous behavior.
Refs CE-354
Note
Cursor Bugbot is generating a summary for commit 8a0e2c8. Configure here.