Skip to content

Commit 97d148a

Browse files
committed
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
1 parent 929a1b5 commit 97d148a

4 files changed

Lines changed: 348 additions & 5 deletions

File tree

‎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

45
import requests
@@ -47,6 +48,8 @@ def do_request(
4748
payload: [dict, str] = None,
4849
files: list = None,
4950
method: str = "GET",
51+
*,
52+
request_timeout: Optional[float] = None,
5053
) -> Response:
5154
if self.encoded_key is None or self.encoded_key == "":
5255
raise APIKeyMissing
@@ -68,7 +71,8 @@ def format_headers(headers_dict):
6871

6972
response = requests.request(
7073
method.upper(), url, headers=headers, data=payload, files=files,
71-
timeout=self.request_timeout, verify=not self.allow_unverified
74+
timeout=self.request_timeout if request_timeout is None else request_timeout,
75+
verify=not self.allow_unverified
7276
)
7377
request_duration = time.time() - start_time
7478

‎socketdev/fullscans/__init__.py‎

Lines changed: 67 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,71 @@ 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+
remaining = deadline - time.monotonic()
998+
if remaining <= 0:
999+
raise APITimeout("Timed out waiting for full scan results")
1000+
try:
1001+
response = self.api.do_request(
1002+
path=path,
1003+
method="GET",
1004+
request_timeout=min(30.0, float(request_timeout), remaining),
1005+
)
1006+
except APIFailure as error:
1007+
if not error.is_transient_error():
1008+
raise
1009+
remaining = deadline - time.monotonic()
1010+
if remaining <= 0:
1011+
raise APITimeout("Timed out waiting for full scan results") from error
1012+
time.sleep(min(interval, remaining))
1013+
interval = min(poll_max_interval_seconds, interval * 1.5)
1014+
continue
1015+
if response.status_code == 202:
1016+
response.close()
1017+
remaining = deadline - time.monotonic()
1018+
if remaining <= 0:
1019+
raise APITimeout("Timed out waiting for full scan results")
1020+
time.sleep(min(interval, remaining))
1021+
interval = min(poll_max_interval_seconds, interval * 1.5)
1022+
continue
1023+
break
9661024

9671025
if response.status_code == 200:
9681026
try:
9691027
stream_str = []
9701028
artifacts = {}
9711029
result = response.text
1030+
response.close()
9721031
result = result.strip('"').strip()
9731032
for line in result.split("\n"):
9741033
if line != '"' and line != "" and line is not None:
@@ -993,6 +1052,7 @@ def stream(self, org_slug: str, full_scan_id: str, use_types: bool = False) -> U
9931052
return artifacts
9941053

9951054
except Exception as e:
1055+
response.close()
9961056
error_message = f"Error parsing stream response: {str(e)}"
9971057
log.error(error_message)
9981058
if use_types:
@@ -1001,7 +1061,10 @@ def stream(self, org_slug: str, full_scan_id: str, use_types: bool = False) -> U
10011061
)
10021062
return {}
10031063

1004-
error_message = response.json().get("error", {}).get("message", "Unknown error")
1064+
try:
1065+
error_message = response.json().get("error", {}).get("message", "Unknown error")
1066+
finally:
1067+
response.close()
10051068
log.error(f"Error streaming full scan: {response.status_code}, message: {error_message}")
10061069
if use_types:
10071070
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)