Skip to content

Commit 847da08

Browse files
authored
Merge branch 'main' into codex/python-sdk-user-agent
2 parents a75398f + 4ade160 commit 847da08

13 files changed

Lines changed: 467 additions & 62 deletions

File tree

‎.github/actions/setup-sfw/action.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ runs:
2424
using: "composite"
2525
steps:
2626
- if: ${{ inputs.python == 'true' || inputs.uv == 'true' }}
27-
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
27+
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
2828
with:
2929
python-version: "3.12"
3030

‎.github/workflows/api-drift-check.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
fetch-depth: 1
2727
persist-credentials: false
2828

29-
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
29+
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
3030
with:
3131
python-version: "3.12"
3232

‎.github/workflows/package-check.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
fetch-depth: 1
2424
persist-credentials: false
2525

26-
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
26+
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
2727
with:
2828
python-version: "3.12"
2929

‎.github/workflows/pr-preview.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ jobs:
9292
fetch-depth: 0
9393
persist-credentials: false
9494

95-
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
95+
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
9696
with:
9797
python-version: "3.12"
9898

‎.github/workflows/release.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
with:
1515
fetch-depth: 0
1616
persist-credentials: false
17-
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
17+
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
1818
with:
1919
python-version: '3.13'
2020

‎CHANGELOG.md‎

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

1414
- The SDK rejects the retired API credential before storing or encoding it.
1515

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

1841
### 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)