Skip to content

Commit 4ade160

Browse files
jdaltonlelia
andauthored
Poll cached full scan results until ready (#115)
* Poll cached full scan results Wait for cached full scan artifacts and bound retries so SDK callers receive completed results or a clear timeout. Assisted-by: Codex: GPT-6 * Fix cached scan CI coverage Exercise the cached stream through the real SDK transport and bump the package version to an unpublished release. Assisted-by: Codex: GPT-6 * Tighten full scan polling and document it Stop polling once the next attempt could not start before the deadline instead of sleeping out the budget, give each request the configured timeout rather than a fixed 30s cap, and document the polling parameters in the README. --------- Co-authored-by: lelia <2418071+lelia@users.noreply.github.com>
1 parent 5f226dc commit 4ade160

7 files changed

Lines changed: 432 additions & 27 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,29 @@
66

77
- The SDK rejects the retired API credential before storing or encoding it.
88

9+
### Fixed: full-scan reads wait for analysis to finish
10+
11+
- `fullscans.stream()` now requests cached results (`?cached=true`) and polls
12+
while the API answers `202 Processing`, instead of treating the first `202`
13+
as a failed result. Polling backs off from 5s to 10s between attempts and
14+
stops when the budget runs out, raising `APITimeout`.
15+
- The polling budget defaults to the SDK's configured `timeout`. Callers can
16+
tune it with the new keyword-only arguments `poll_timeout_seconds`,
17+
`poll_initial_interval_seconds` and `poll_max_interval_seconds`. Each
18+
request is bounded by the configured `timeout` or the remaining budget,
19+
whichever is shorter, and polling stops early once the next attempt could
20+
not start before the budget runs out.
21+
- Transient failures (HTTP 408/502/503/504, timeouts, dropped connections) are
22+
retried within the budget. Authorization, not-found and quota errors still
23+
raise immediately.
24+
- Invalid polling settings (zero, negative, non-finite, booleans, or an
25+
initial interval above the maximum) raise `ValueError` before any request is
26+
sent.
27+
- The organization slug and full scan ID are now URL-encoded in the request
28+
path.
29+
- `API.do_request()` accepts an optional keyword-only `request_timeout` that
30+
overrides the configured timeout for a single call.
31+
932
## 3.6.1
1033

1134
### Security: patched releases of certifi, uv and virtualenv

‎README.rst‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,11 @@ Stream a diff between two full scans. Returns a scan diff.
291291

292292
fullscans.stream(org_slug, full_scan_id, use_types=False)
293293
"""""""""""""""""""""""""""""""""""""""""""""""""""""""""
294-
Stream all SBOM artifacts for a full scan.
294+
Stream all SBOM artifacts for a full scan. If the scan is still being analyzed, the SDK polls
295+
cached results until they are ready, backing off from 5 to 10 seconds between attempts, and
296+
raises ``APITimeout`` if they are not ready within the polling budget. Transient failures
297+
(HTTP 408/502/503/504, timeouts, dropped connections) are retried within the budget; other
298+
errors raise immediately.
295299

296300
**Usage:**
297301

@@ -304,11 +308,17 @@ Stream all SBOM artifacts for a full scan.
304308
# With typed response
305309
print(socket.fullscans.stream("org_slug", "full_scan_id", use_types=True))
306310
311+
# Wait up to 10 minutes for analysis to finish
312+
print(socket.fullscans.stream("org_slug", "full_scan_id", poll_timeout_seconds=600))
313+
307314
**PARAMETERS:**
308315

309316
- **org_slug (str)** - The organization name
310317
- **full_scan_id (str)** - The ID of the full scan
311318
- **use_types (bool)** - Whether to return typed response objects (default: False)
319+
- **poll_timeout_seconds (float)** - Keyword-only. Total time to wait for results (default: the SDK's configured ``timeout``)
320+
- **poll_initial_interval_seconds (float)** - Keyword-only. Wait before the first retry (default: 5.0)
321+
- **poll_max_interval_seconds (float)** - Keyword-only. Longest wait between retries (default: 10.0)
312322

313323
fullscans.metadata(org_slug, full_scan_id, use_types=False)
314324
"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""

‎socketdev/core/api.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import base64
2+
from typing import Optional
23
from socketdev.log import log
34
from socketdev.core.retired_token import reject_retired_basic_auth_token
45

@@ -49,6 +50,8 @@ def do_request(
4950
payload: [dict, str] = None,
5051
files: list = None,
5152
method: str = "GET",
53+
*,
54+
request_timeout: Optional[float] = None,
5255
) -> Response:
5356
if self.encoded_key is None or self.encoded_key == "":
5457
raise APIKeyMissing
@@ -70,7 +73,8 @@ def format_headers(headers_dict):
7073

7174
response = requests.request(
7275
method.upper(), url, headers=headers, data=payload, files=files,
73-
timeout=self.request_timeout, verify=not self.allow_unverified
76+
timeout=self.request_timeout if request_timeout is None else request_timeout,
77+
verify=not self.allow_unverified
7478
)
7579
request_duration = time.time() - start_time
7680

‎socketdev/fullscans/__init__.py‎

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
from typing import Any, Dict, List, Optional, Union
55
from dataclasses import dataclass, asdict, field
66
import urllib.parse
7+
import math
8+
import time
79
from ..core.dedupe import Dedupe
810
from ..core.enums import unknown_enum_value
911
from ..utils import IntegrationType, Utils
12+
from ..exceptions import APIFailure, APITimeout
1013

1114
log = logging.getLogger("socketdev")
1215

@@ -960,15 +963,65 @@ def stream_diff(
960963
)
961964
return {}
962965

963-
def stream(self, org_slug: str, full_scan_id: str, use_types: bool = False) -> Union[dict, FullScanStreamResponse]:
964-
path = "orgs/" + org_slug + "/full-scans/" + full_scan_id
965-
response = self.api.do_request(path=path, method="GET")
966+
def stream(
967+
self,
968+
org_slug: str,
969+
full_scan_id: str,
970+
use_types: bool = False,
971+
*,
972+
poll_timeout_seconds: Optional[float] = None,
973+
poll_initial_interval_seconds: float = 5.0,
974+
poll_max_interval_seconds: float = 10.0,
975+
) -> Union[dict, FullScanStreamResponse]:
976+
request_timeout = self.api.request_timeout
977+
timeout = request_timeout if poll_timeout_seconds is None else poll_timeout_seconds
978+
values = (timeout, poll_initial_interval_seconds, poll_max_interval_seconds, request_timeout)
979+
if any(
980+
isinstance(value, bool)
981+
or not isinstance(value, (int, float))
982+
or not math.isfinite(value)
983+
or value <= 0
984+
for value in values
985+
):
986+
raise ValueError("Full scan polling timeout and intervals must be positive finite numbers")
987+
if poll_initial_interval_seconds > poll_max_interval_seconds:
988+
raise ValueError("Initial polling interval must not exceed maximum polling interval")
989+
990+
path = (
991+
"orgs/" + urllib.parse.quote(org_slug, safe="") + "/full-scans/"
992+
+ urllib.parse.quote(full_scan_id, safe="") + "?cached=true"
993+
)
994+
deadline = time.monotonic() + timeout
995+
interval = poll_initial_interval_seconds
996+
while True:
997+
retry_error = None
998+
try:
999+
response = self.api.do_request(
1000+
path=path,
1001+
method="GET",
1002+
request_timeout=min(float(request_timeout), max(deadline - time.monotonic(), 0.001)),
1003+
)
1004+
except APIFailure as error:
1005+
if not error.is_transient_error():
1006+
raise
1007+
retry_error = error
1008+
else:
1009+
if response.status_code != 202:
1010+
break
1011+
response.close()
1012+
# Give up now if the next attempt could not start before the deadline,
1013+
# rather than sleeping out the rest of the budget and never polling again.
1014+
if deadline - time.monotonic() <= interval:
1015+
raise APITimeout("Timed out waiting for full scan results") from retry_error
1016+
time.sleep(interval)
1017+
interval = min(poll_max_interval_seconds, interval * 1.5)
9661018

9671019
if response.status_code == 200:
9681020
try:
9691021
stream_str = []
9701022
artifacts = {}
9711023
result = response.text
1024+
response.close()
9721025
result = result.strip('"').strip()
9731026
for line in result.split("\n"):
9741027
if line != '"' and line != "" and line is not None:
@@ -993,6 +1046,7 @@ def stream(self, org_slug: str, full_scan_id: str, use_types: bool = False) -> U
9931046
return artifacts
9941047

9951048
except Exception as e:
1049+
response.close()
9961050
error_message = f"Error parsing stream response: {str(e)}"
9971051
log.error(error_message)
9981052
if use_types:
@@ -1001,7 +1055,10 @@ def stream(self, org_slug: str, full_scan_id: str, use_types: bool = False) -> U
10011055
)
10021056
return {}
10031057

1004-
error_message = response.json().get("error", {}).get("message", "Unknown error")
1058+
try:
1059+
error_message = response.json().get("error", {}).get("message", "Unknown error")
1060+
finally:
1061+
response.close()
10051062
log.error(f"Error streaming full scan: {response.status_code}, message: {error_message}")
10061063
if use_types:
10071064
return FullScanStreamResponse.from_dict(
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Reusable localhost HTTP fixture for real Socket SDK transport tests."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import threading
7+
from dataclasses import dataclass
8+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9+
from typing import Callable, Dict, List, Optional, Tuple
10+
from urllib.parse import parse_qs, urlsplit
11+
12+
13+
@dataclass
14+
class SocketRequest:
15+
method: str
16+
path: str
17+
query: Dict[str, List[str]]
18+
headers: Dict[str, str]
19+
body: bytes
20+
21+
22+
@dataclass
23+
class SocketResponse:
24+
status: int
25+
payload: object
26+
raw_body: Optional[bytes] = None
27+
28+
29+
Route = Callable[[SocketRequest], SocketResponse]
30+
31+
32+
class SocketApiFixture:
33+
def __init__(self) -> None:
34+
self.requests: List[SocketRequest] = []
35+
self._routes: Dict[Tuple[str, str], Route] = {}
36+
self._server: Optional[ThreadingHTTPServer] = None
37+
self._thread: Optional[threading.Thread] = None
38+
39+
@property
40+
def base_url(self) -> str:
41+
if self._server is None:
42+
raise RuntimeError("Socket API fixture is not running")
43+
host, port = self._server.server_address
44+
return f"http://{host}:{port}/v0"
45+
46+
def route(
47+
self,
48+
method: str,
49+
path: str,
50+
response: SocketResponse | Route,
51+
) -> None:
52+
if callable(response):
53+
handler = response
54+
else:
55+
56+
def handler(_request: SocketRequest) -> SocketResponse:
57+
return response
58+
59+
self._routes[(method.upper(), path)] = handler
60+
61+
def start(self) -> None:
62+
fixture = self
63+
64+
class Handler(BaseHTTPRequestHandler):
65+
def do_GET(self) -> None:
66+
self._dispatch()
67+
68+
def do_POST(self) -> None:
69+
self._dispatch()
70+
71+
def _dispatch(self) -> None:
72+
parsed = urlsplit(self.path)
73+
length = int(self.headers.get("Content-Length", "0"))
74+
request = SocketRequest(
75+
method=self.command,
76+
path=parsed.path.removeprefix("/v0/"),
77+
query=parse_qs(parsed.query, keep_blank_values=True),
78+
headers=dict(self.headers.items()),
79+
body=self.rfile.read(length),
80+
)
81+
fixture.requests.append(request)
82+
route = fixture._routes.get((request.method, request.path))
83+
if route is None and request.path == "organizations":
84+
response = SocketResponse(
85+
200,
86+
{"organizations": {"org-fixture": {"slug": "fixture-org"}}},
87+
)
88+
elif route is None:
89+
response = SocketResponse(404, {"error": {"message": "Not found"}})
90+
else:
91+
response = route(request)
92+
body = response.raw_body
93+
if body is None:
94+
body = json.dumps(response.payload).encode("utf-8")
95+
self.send_response(response.status)
96+
self.send_header("Content-Type", "application/json")
97+
self.send_header("Content-Length", str(len(body)))
98+
self.end_headers()
99+
self.wfile.write(body)
100+
101+
def log_message(self, _format: str, *args: object) -> None:
102+
return
103+
104+
self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
105+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
106+
self._thread.start()
107+
108+
def close(self) -> None:
109+
if self._server is not None:
110+
self._server.shutdown()
111+
self._server.server_close()
112+
self._server = None
113+
if self._thread is not None:
114+
self._thread.join(timeout=2)
115+
self._thread = None
116+
117+
118+
def socket_api_fixture() -> SocketApiFixture:
119+
fixture = SocketApiFixture()
120+
fixture.start()
121+
return fixture

0 commit comments

Comments
 (0)