mirror of
https://github.com/usestrix/strix.git
synced 2026-11-15 09:11:26 +00:00
Merge 01a4b24dc1 into cb581bf93d
This commit is contained in:
commit
66ffddc8ad
3 changed files with 139 additions and 7 deletions
|
|
@ -171,7 +171,14 @@ def request(
|
|||
body: dict[str, Any] | None = None,
|
||||
stream: bool = False,
|
||||
idempotency_key: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> requests.Response:
|
||||
"""Send one platform API request.
|
||||
|
||||
``timeout`` can only shorten the configured request timeout, so a caller
|
||||
with a smaller remaining budget (``--wait``) cannot be held past it by a
|
||||
stalled connection.
|
||||
"""
|
||||
url = f"{app_url()}/api/v1{path}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_token(token)}",
|
||||
|
|
@ -193,7 +200,7 @@ def request(
|
|||
}
|
||||
or None,
|
||||
json=body,
|
||||
timeout=_timeout_s,
|
||||
timeout=_timeout_s if timeout is None else min(_timeout_s, timeout),
|
||||
stream=stream,
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1111,10 +1111,21 @@ def _poll(
|
|||
as_json: bool,
|
||||
wait_timeout: float,
|
||||
) -> Any:
|
||||
"""Poll a GET path until its status is final. Returns the last response."""
|
||||
"""Poll a GET path until its status is final. Returns the last response.
|
||||
|
||||
``wait_timeout`` bounds the whole wait: no request starts once the deadline
|
||||
has passed, each request's timeout is capped at the remaining budget, and a
|
||||
response that only arrives after the deadline is reported as a timeout even
|
||||
when it is final, so ``--wait`` never succeeds late.
|
||||
"""
|
||||
deadline = time.monotonic() + wait_timeout
|
||||
while True:
|
||||
current: Any = http.check(http.request("GET", path, token=token))
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise _wait_timeout_error(wait_timeout)
|
||||
current: Any = http.check(http.request("GET", path, token=token, timeout=remaining))
|
||||
if time.monotonic() > deadline:
|
||||
raise _wait_timeout_error(wait_timeout)
|
||||
fields = cast("dict[str, Any]", current) if isinstance(current, dict) else {}
|
||||
status = str(fields.get("status", ""))
|
||||
if status.lower() in _TERMINAL_STATUSES:
|
||||
|
|
@ -1125,8 +1136,12 @@ def _poll(
|
|||
)
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise http.CloudError(
|
||||
f"wait timed out after {wait_timeout:g} seconds; the remote operation is still "
|
||||
"running. Re-run its get command to check the status."
|
||||
)
|
||||
raise _wait_timeout_error(wait_timeout)
|
||||
time.sleep(min(_WAIT_POLL_S, remaining))
|
||||
|
||||
|
||||
def _wait_timeout_error(wait_timeout: float) -> http.CloudError:
|
||||
return http.CloudError(
|
||||
f"wait timed out after {wait_timeout:g} seconds; the remote operation is still "
|
||||
"running. Re-run its get command to check the status."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -742,6 +742,116 @@ def test_wait_timeout_is_bounded(monkeypatch: pytest.MonkeyPatch, capsys: Any) -
|
|||
assert "wait timed out" in json.loads(capsys.readouterr().out)["error"]
|
||||
|
||||
|
||||
class _FakeClock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 0.0
|
||||
|
||||
def monotonic(self) -> float:
|
||||
return self.now
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
self.now += seconds
|
||||
|
||||
|
||||
def _start_scan_and_wait(wait_timeout: str) -> int:
|
||||
return cloud.run_cloud(
|
||||
[
|
||||
"scans",
|
||||
"start",
|
||||
"--domain-ids",
|
||||
"domain-1",
|
||||
"--wait",
|
||||
"--wait-timeout",
|
||||
wait_timeout,
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_wait_starts_no_poll_after_the_deadline(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
clock = _FakeClock()
|
||||
poll_starts: list[float] = []
|
||||
|
||||
def response(method: str, _path: str, **_kwargs: Any) -> FakeResponse:
|
||||
if method == "POST":
|
||||
return FakeResponse({"id": "scan-1", "status": "pending"})
|
||||
poll_starts.append(clock.now)
|
||||
clock.now += 0.1
|
||||
return FakeResponse({"id": "scan-1", "status": "running"})
|
||||
|
||||
monkeypatch.setattr(http, "request", response)
|
||||
monkeypatch.setattr(runner, "time", clock)
|
||||
|
||||
assert _start_scan_and_wait("1") == 1
|
||||
assert poll_starts == [0.0]
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert "wait timed out" in payload["error"]
|
||||
assert payload["operation_id"] == "scan-1"
|
||||
|
||||
|
||||
def test_wait_reports_a_final_status_received_after_the_deadline_as_a_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
clock = _FakeClock()
|
||||
|
||||
def response(method: str, _path: str, **_kwargs: Any) -> FakeResponse:
|
||||
if method == "POST":
|
||||
return FakeResponse({"id": "scan-1", "status": "pending"})
|
||||
clock.now += 30.0
|
||||
return FakeResponse({"id": "scan-1", "status": "completed"})
|
||||
|
||||
monkeypatch.setattr(http, "request", response)
|
||||
monkeypatch.setattr(runner, "time", clock)
|
||||
|
||||
assert _start_scan_and_wait("1") == 1
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert "wait timed out" in payload["error"]
|
||||
assert payload["operation_id"] == "scan-1"
|
||||
|
||||
|
||||
def test_wait_caps_each_poll_request_at_the_remaining_budget(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
clock = _FakeClock()
|
||||
poll_timeouts: list[float | None] = []
|
||||
|
||||
def response(method: str, _path: str, **kwargs: Any) -> FakeResponse:
|
||||
if method == "POST":
|
||||
return FakeResponse({"id": "scan-1", "status": "pending"})
|
||||
poll_timeouts.append(kwargs.get("timeout"))
|
||||
status = "running" if len(poll_timeouts) == 1 else "completed"
|
||||
return FakeResponse({"id": "scan-1", "status": status})
|
||||
|
||||
monkeypatch.setattr(http, "request", response)
|
||||
monkeypatch.setattr(runner, "time", clock)
|
||||
|
||||
assert _start_scan_and_wait("20") == 0
|
||||
assert poll_timeouts == [20.0, 5.0]
|
||||
assert json.loads(capsys.readouterr().out)["status"] == "completed"
|
||||
|
||||
|
||||
def test_request_timeout_override_only_shortens_the_configured_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
timeouts: list[float] = []
|
||||
|
||||
def fake_request(*_args: Any, **kwargs: Any) -> FakeResponse:
|
||||
timeouts.append(kwargs["timeout"])
|
||||
return FakeResponse({})
|
||||
|
||||
monkeypatch.setattr(http, "_timeout_s", 120.0)
|
||||
monkeypatch.setattr(http, "api_token", lambda _token=None: "token")
|
||||
monkeypatch.setattr(http.requests, "request", fake_request)
|
||||
|
||||
http.request("GET", "/scans/scan-1")
|
||||
http.request("GET", "/scans/scan-1", timeout=5.0)
|
||||
http.request("GET", "/scans/scan-1", timeout=600.0)
|
||||
|
||||
assert timeouts == [120.0, 5.0, 120.0]
|
||||
|
||||
|
||||
def test_wait_interruption_returns_130_with_remote_operation_id(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue