mirror of
https://github.com/usestrix/strix.git
synced 2026-09-07 08:25:56 +00:00
fix(cloud): print top-up instructions on 402 and guide oversize or archive --source (#1242)
- Every payment-required error now ends with a "Next step" line: the platform hint when one is sent, else the topup command and the billing URL for the configured platform. JSON output gets the same text as next_step. The platform hint is no longer repeated inside the error. - An archive file passed to --source is rejected with guidance to pass the directory instead, which packs and excludes deps/build output. - An oversize archive names its largest files and points to --exclude and --dry-run --show-files. - uploads request help points to scans start --source for local code.
This commit is contained in:
parent
1edafd3e80
commit
5d015df6b1
6 changed files with 202 additions and 15 deletions
|
|
@ -34,13 +34,30 @@ EXIT_AUTH = 4
|
|||
EXIT_PAYMENT = 5
|
||||
|
||||
|
||||
class CloudError(Exception):
|
||||
"""A failed cloud command. Carries the process exit code."""
|
||||
TOPUP_COMMAND = "strix cloud billing topup --credits <count>"
|
||||
BALANCE_COMMAND = "strix cloud billing credits"
|
||||
|
||||
def __init__(self, message: str, *, exit_code: int = EXIT_ERROR, payload: Any = None) -> None:
|
||||
|
||||
class CloudError(Exception):
|
||||
"""A failed cloud command. Carries the process exit code.
|
||||
|
||||
`next_step` is a short recovery instruction that the runner prints on its
|
||||
own line after the error, so a person or an agent can act without reading
|
||||
the docs.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
exit_code: int = EXIT_ERROR,
|
||||
payload: Any = None,
|
||||
next_step: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.exit_code = exit_code
|
||||
self.payload = payload
|
||||
self.next_step = next_step
|
||||
|
||||
|
||||
class CloudTransportError(CloudError):
|
||||
|
|
@ -349,13 +366,43 @@ def check(response: requests.Response) -> Any:
|
|||
error_code = error_code or str(nested.get("code") or "")
|
||||
detail = str(nested.get("message") or detail)
|
||||
message = detail or f"HTTP {response.status_code}"
|
||||
if error_code == "scan_credit_limit_reached":
|
||||
raise CloudError(message, exit_code=EXIT_PAYMENT, payload=data)
|
||||
if error_code == "scan_credit_limit_reached" or response.status_code == 402:
|
||||
raise payment_required_error(data, detail=detail)
|
||||
if response.status_code in (401, 403):
|
||||
raise CloudError(message, exit_code=EXIT_AUTH, payload=data)
|
||||
if response.status_code == 402:
|
||||
hint = detail or (
|
||||
"not enough credits. Run `strix cloud billing topup --credits N` to buy credits."
|
||||
)
|
||||
raise CloudError(hint, exit_code=EXIT_PAYMENT, payload=data)
|
||||
raise CloudError(message, exit_code=EXIT_ERROR, payload=data)
|
||||
|
||||
|
||||
def topup_url() -> str:
|
||||
return f"{app_url()}/settings/billing"
|
||||
|
||||
|
||||
def topup_next_step(url: str | None = None) -> str:
|
||||
return (
|
||||
f"Buy credits with `{TOPUP_COMMAND}` or at {url or topup_url()}. "
|
||||
f"Run `{BALANCE_COMMAND}` to see the balance. Then retry this command."
|
||||
)
|
||||
|
||||
|
||||
def payment_required_error(data: Any, *, detail: str = "") -> CloudError:
|
||||
"""Build the error for an exhausted credit balance.
|
||||
|
||||
The platform sends the recovery instruction in `hint` and repeats it inside
|
||||
`detail`. The CLI shows the instruction once, on its own line, and adds its
|
||||
own instruction when the platform sends none.
|
||||
"""
|
||||
server_hint = ""
|
||||
server_url: str | None = None
|
||||
if isinstance(data, dict):
|
||||
raw = cast("dict[str, Any]", data)
|
||||
server_hint = str(raw.get("hint") or "").strip()
|
||||
raw_url = raw.get("topup_url")
|
||||
if isinstance(raw_url, str) and raw_url.startswith("https://"):
|
||||
server_url = raw_url
|
||||
message = detail.strip()
|
||||
if server_hint and message.endswith(server_hint):
|
||||
message = message[: -len(server_hint)].strip()
|
||||
if not message:
|
||||
message = "Not enough credits to run this command."
|
||||
next_step = server_hint or topup_next_step(server_url)
|
||||
return CloudError(message, exit_code=EXIT_PAYMENT, payload=data, next_step=next_step)
|
||||
|
|
|
|||
|
|
@ -1035,10 +1035,14 @@ def _emit_error(
|
|||
payload = {"error": str(exc)}
|
||||
if exc.payload is not None:
|
||||
payload["detail"] = exc.payload
|
||||
if exc.next_step:
|
||||
payload["next_step"] = exc.next_step
|
||||
sys.stdout.write(json.dumps(payload, indent=2, default=str) + "\n")
|
||||
return
|
||||
target = Console(stderr=True) if to_stderr else console
|
||||
target.print(f"[red]Error:[/] {escape(sanitize_terminal_text(exc))}")
|
||||
if exc.next_step:
|
||||
target.print(f"[yellow]Next step:[/] {escape(sanitize_terminal_text(exc.next_step))}")
|
||||
|
||||
|
||||
def _emit_interrupted(console: Console, *, as_json: bool, to_stderr: bool) -> None:
|
||||
|
|
|
|||
|
|
@ -203,6 +203,17 @@ def prepare_source(
|
|||
"""Select safe source files and build a bounded temporary ZIP archive."""
|
||||
source = Path(value).expanduser().resolve()
|
||||
if not source.is_dir():
|
||||
if source.is_file() and (
|
||||
source.name.lower().endswith(_ARCHIVE_SUFFIXES) or _has_archive_magic(source)
|
||||
):
|
||||
raise http.CloudError(
|
||||
f"--source must be a directory, not an archive: {source}",
|
||||
next_step=(
|
||||
"Extract the archive and pass the directory to --source. Strix packs the "
|
||||
"directory and excludes dependencies, build output, and secret-like files. "
|
||||
"Add --dry-run --show-files to review the selection first."
|
||||
),
|
||||
)
|
||||
raise http.CloudError(f"--source must be a directory: {source}")
|
||||
manifest = select_source(
|
||||
source,
|
||||
|
|
@ -224,14 +235,34 @@ def prepare_source(
|
|||
archive_bytes = archive_path.stat().st_size
|
||||
if archive_bytes > MAX_ARCHIVE_BYTES:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
raise http.CloudError(
|
||||
"source archive is larger than the 50 MB upload limit; narrow --source or "
|
||||
"add --exclude patterns."
|
||||
)
|
||||
raise _archive_too_large_error(manifest, archive_bytes)
|
||||
digest = _sha256(archive_path)
|
||||
return SourceBundle(manifest, archive_path, archive_bytes, digest)
|
||||
|
||||
|
||||
_LARGEST_FILES_SHOWN = 5
|
||||
|
||||
|
||||
def _format_mib(size: int) -> str:
|
||||
return f"{size / (1024 * 1024):.1f} MiB"
|
||||
|
||||
|
||||
def _archive_too_large_error(manifest: SourceManifest, archive_bytes: int) -> http.CloudError:
|
||||
"""Name the largest selected files so the user knows what to exclude."""
|
||||
largest = sorted(manifest.files, key=lambda item: item.size, reverse=True)
|
||||
listed = ", ".join(
|
||||
f"{item.archive_name} ({_format_mib(item.size)})" for item in largest[:_LARGEST_FILES_SHOWN]
|
||||
)
|
||||
return http.CloudError(
|
||||
f"the source archive is {_format_mib(archive_bytes)}, larger than the "
|
||||
f"{_format_mib(MAX_ARCHIVE_BYTES)} upload limit. Largest files: {listed}.",
|
||||
next_step=(
|
||||
"Add --exclude patterns for large files or directories, or point --source at a "
|
||||
"smaller directory. Run with --dry-run --show-files to review the selection."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def select_source(
|
||||
source: Path,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -1066,7 +1066,8 @@ SPEC: dict[str, dict[str, Cmd]] = {
|
|||
"request": Cmd(
|
||||
"POST",
|
||||
"/uploads/request",
|
||||
"Request an upload URL.",
|
||||
"Request an upload URL. To scan local source, prefer `strix cloud scans start "
|
||||
"--source DIR`, which packs, uploads, and starts the scan in one step.",
|
||||
body=(
|
||||
P("file_name", required=True, help="File name."),
|
||||
P("file_size", "int", required=True, help="File size in bytes."),
|
||||
|
|
|
|||
|
|
@ -534,6 +534,69 @@ def test_insufficient_credits_exits_with_payment_code(monkeypatch: pytest.Monkey
|
|||
assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1"]) == http.EXIT_PAYMENT
|
||||
|
||||
|
||||
def test_insufficient_credits_always_prints_topup_instruction(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
lambda *_a, **_k: FakeResponse(
|
||||
status_code=402,
|
||||
payload={"detail": "Out of credits.", "code": "scan_credit_limit_reached"},
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
argv = ["scans", "start", "--domain-ids", "d1", "--app-url", "https://app.strix.ai"]
|
||||
assert cloud.run_cloud(argv) == http.EXIT_PAYMENT
|
||||
output = " ".join(capsys.readouterr().out.split())
|
||||
assert "Error: Out of credits." in output
|
||||
assert "Next step:" in output
|
||||
assert "strix cloud billing topup --credits <count>" in output
|
||||
assert "https://app.strix.ai/settings/billing" in output
|
||||
assert "strix cloud billing credits" in output
|
||||
|
||||
|
||||
def test_insufficient_credits_shows_platform_hint_once(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
hint = "Buy credits at https://app.strix.ai/settings/billing. Then retry this request."
|
||||
payload = {
|
||||
"detail": f"Out of credits. {hint}",
|
||||
"code": "scan_credit_limit_reached",
|
||||
"hint": hint,
|
||||
"topup_url": "https://app.strix.ai/settings/billing",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload=payload)
|
||||
)
|
||||
assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1", "--json"]) == http.EXIT_PAYMENT
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"] == "Out of credits."
|
||||
assert result["next_step"] == hint
|
||||
assert result["topup_url"] == "https://app.strix.ai/settings/billing"
|
||||
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1"]) == http.EXIT_PAYMENT
|
||||
output = " ".join(capsys.readouterr().out.split())
|
||||
assert output.count(hint) == 1
|
||||
assert "Error: Out of credits." in output
|
||||
assert f"Next step: {hint}" in output
|
||||
|
||||
|
||||
def test_payment_required_without_body_names_the_topup_command(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload={})
|
||||
)
|
||||
argv = ["scans", "start", "--domain-ids", "d1", "--json", "--app-url", "https://app.strix.ai"]
|
||||
assert cloud.run_cloud(argv) == http.EXIT_PAYMENT
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"] == "Not enough credits to run this command."
|
||||
assert "strix cloud billing topup --credits <count>" in result["next_step"]
|
||||
assert "https://app.strix.ai/settings/billing" in result["next_step"]
|
||||
|
||||
|
||||
def test_data_rejects_non_object() -> None:
|
||||
assert cloud.run_cloud(["scans", "start", "--data", "[1,2]"]) == http.EXIT_USAGE
|
||||
assert cloud.run_cloud(["scans", "start", "--data", "not json"]) == http.EXIT_USAGE
|
||||
|
|
|
|||
|
|
@ -655,3 +655,44 @@ def test_incomplete_upload_credentials_delete_the_reserved_upload(
|
|||
monkeypatch.setattr(http, "request", fake_request)
|
||||
assert cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes", "--json"]) == 1
|
||||
assert ("DELETE", "/uploads/upload-incomplete") in paths
|
||||
|
||||
|
||||
def test_archive_source_is_rejected_with_directory_guidance(tmp_path: Path) -> None:
|
||||
archive = tmp_path / "backend.zip"
|
||||
with zipfile.ZipFile(archive, "w") as bundle:
|
||||
bundle.writestr("app.py", "print('safe')\n")
|
||||
|
||||
with pytest.raises(http.CloudError, match="not an archive") as raised:
|
||||
source_upload.prepare_source(
|
||||
str(archive),
|
||||
include_hidden=False,
|
||||
include_sensitive=False,
|
||||
include_archives=False,
|
||||
exclude=[],
|
||||
)
|
||||
assert raised.value.next_step is not None
|
||||
assert "--source" in raised.value.next_step
|
||||
assert "--dry-run --show-files" in raised.value.next_step
|
||||
|
||||
|
||||
def test_oversize_archive_names_largest_files_and_exclude_guidance(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
(tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8")
|
||||
(tmp_path / "big.bin").write_bytes(os.urandom(4096))
|
||||
monkeypatch.setattr(source_upload, "MAX_ARCHIVE_BYTES", 1024)
|
||||
|
||||
with pytest.raises(http.CloudError, match=r"larger than the 0\.0 MiB upload limit") as raised:
|
||||
source_upload.prepare_source(
|
||||
str(tmp_path),
|
||||
include_hidden=False,
|
||||
include_sensitive=False,
|
||||
include_archives=False,
|
||||
exclude=[],
|
||||
)
|
||||
message = str(raised.value)
|
||||
assert message.index("big.bin") < message.index("app.py")
|
||||
assert raised.value.next_step is not None
|
||||
assert "--exclude" in raised.value.next_step
|
||||
assert "--dry-run --show-files" in raised.value.next_step
|
||||
assert not list(tmp_path.glob("strix-source-*.zip"))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue