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:
alex s 2026-09-02 15:26:37 -04:00 committed by GitHub
parent 1edafd3e80
commit 5d015df6b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 202 additions and 15 deletions

View file

@ -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)

View file

@ -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:

View file

@ -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,
*,

View file

@ -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."),

View file

@ -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

View file

@ -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"))