Skip to content

Commit e9325c9

Browse files
committed
fix(ctl): guard marketplace pagination and JSON parsing, clarify 4xx messages
1 parent 6a55068 commit e9325c9

2 files changed

Lines changed: 78 additions & 8 deletions

File tree

infrahub_sdk/ctl/marketplace.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,17 @@ async def _fetch_listing(
105105
while True:
106106
resp = await client.get(url, params=params)
107107
resp.raise_for_status()
108-
payload = resp.json()
108+
payload = _json_or_fail(resp, url)
109109
items.extend(payload.get("items", []))
110110
total_count = payload.get("total_count", len(items))
111111
page_info = payload.get("page_info") or {}
112-
if limit is not None or not page_info.get("has_next_page"):
112+
cursor = page_info.get("end_cursor")
113+
# Stop when a single page was requested, when the server reports no more
114+
# pages, or when it claims a next page but gives no cursor to follow
115+
# (guards against an infinite loop re-requesting the same page).
116+
if limit is not None or not page_info.get("has_next_page") or not cursor:
113117
break
114-
params = {**params, "cursor": page_info.get("end_cursor")}
118+
params = {**params, "cursor": cursor}
115119
return items, total_count
116120

117121

@@ -170,7 +174,7 @@ async def _run_listing(
170174
except httpx.HTTPError as exc:
171175
error_class = _classify_http_error(exc)
172176
if error_class is _ErrorClass.NOT_FOUND:
173-
_fail(error_class, f"Marketplace listing not found on {_host_from(resolved_url)}: {exc}")
177+
_fail(error_class, f"Marketplace request to {_host_from(resolved_url)} failed: {exc}")
174178
detail = str(exc) or type(exc).__name__
175179
_fail(_ErrorClass.NETWORK, f"Marketplace request failed: {detail}")
176180
if json_output:
@@ -198,6 +202,19 @@ def _classify_http_error(exc: httpx.HTTPError) -> _ErrorClass:
198202
return _ErrorClass.NETWORK
199203

200204

205+
def _json_or_fail(resp: httpx.Response, source_url: str) -> Any:
206+
"""Parse a JSON response body, failing with a network-class error on invalid JSON.
207+
208+
A 200 with a malformed body is a broken response, not user error, so it is
209+
reported cleanly (exit 2) rather than leaking a raw ``JSONDecodeError`` and
210+
traceback — which would also corrupt ``--json`` output.
211+
"""
212+
try:
213+
return resp.json()
214+
except ValueError:
215+
_fail(_ErrorClass.NETWORK, f"Response from {_host_from(source_url)} is not valid JSON.")
216+
217+
201218
def _mkdir_or_fail(path: Path) -> None:
202219
try:
203220
path.mkdir(parents=True, exist_ok=True)
@@ -422,7 +439,7 @@ async def _fetch_detail(
422439
if resp.status_code == 404:
423440
_fail(_ErrorClass.NOT_FOUND, f"No collection named '{namespace}/{name}' found on {_host_from(base_url)}.")
424441
resp.raise_for_status()
425-
return "collection", resp.json()
442+
return "collection", _json_or_fail(resp, base_url)
426443

427444
schema_resp, collection_resp = await asyncio.gather(
428445
client.get(_detail_url(base_url, "schema", namespace, name)),
@@ -435,9 +452,9 @@ async def _fetch_detail(
435452
f"[yellow]Note: '{namespace}/{name}' exists as both a schema and a collection. "
436453
"Resolving as schema. Pass --collection to force the collection path."
437454
)
438-
return "schema", schema_resp.json()
455+
return "schema", _json_or_fail(schema_resp, base_url)
439456
if isinstance(collection_resp, httpx.Response) and collection_resp.status_code == 200:
440-
return "collection", collection_resp.json()
457+
return "collection", _json_or_fail(collection_resp, base_url)
441458

442459
if _is_transport_failure(schema_resp) or _is_transport_failure(collection_resp):
443460
_fail(
@@ -527,7 +544,7 @@ async def show(
527544
except httpx.HTTPError as exc:
528545
error_class = _classify_http_error(exc)
529546
if error_class is _ErrorClass.NOT_FOUND:
530-
_fail(error_class, f"Marketplace listing not found on {_host_from(resolved_url)}: {exc}")
547+
_fail(error_class, f"Marketplace request for '{parsed.namespace}/{parsed.name}' failed: {exc}")
531548
message = str(exc) or type(exc).__name__
532549
_fail(_ErrorClass.NETWORK, f"Marketplace request failed: {message}")
533550
if json_output:

tests/unit/ctl/test_marketplace_app.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,3 +1012,56 @@ def test_list_network_error_exits_2_regression(httpx_mock: HTTPXMock) -> None:
10121012

10131013
assert result.exit_code == 2
10141014
assert "Marketplace request failed" in result.output
1015+
1016+
1017+
def test_list_stops_when_next_page_has_null_cursor(httpx_mock: HTTPXMock) -> None:
1018+
"""has_next_page=true with a null end_cursor must not loop forever.
1019+
1020+
Only one page is mocked. If the loop followed the (null) cursor it would issue
1021+
a second request that pytest-httpx has no response for, failing the test.
1022+
"""
1023+
httpx_mock.add_response(
1024+
method="GET",
1025+
url="https://marketplace.infrahub.app/api/v1/schemas",
1026+
json={
1027+
"items": [_schema_item("infrahub", "a", display="A", semver="1.0.0", downloads=1, tags=[])],
1028+
"page_info": {"has_next_page": True, "end_cursor": None},
1029+
"total_count": 1,
1030+
},
1031+
)
1032+
result = runner.invoke(app, ["list"])
1033+
1034+
assert result.exit_code == 0
1035+
assert "infrahub/a" in result.output
1036+
1037+
1038+
def test_list_invalid_json_body_is_network_error(httpx_mock: HTTPXMock) -> None:
1039+
"""A 200 response with a non-JSON body is reported as a clean network error (exit 2)."""
1040+
httpx_mock.add_response(
1041+
method="GET",
1042+
url="https://marketplace.infrahub.app/api/v1/schemas",
1043+
text="<html>not json</html>",
1044+
)
1045+
result = runner.invoke(app, ["list"])
1046+
1047+
assert result.exit_code == 2
1048+
assert "not valid JSON" in result.output
1049+
1050+
1051+
def test_show_invalid_json_body_is_network_error(httpx_mock: HTTPXMock) -> None:
1052+
"""A schema detail endpoint returning 200 with a malformed body exits 2, not a traceback."""
1053+
httpx_mock.add_response(
1054+
method="GET",
1055+
url="https://marketplace.infrahub.app/api/v1/schemas/infrahub/vlan",
1056+
text="<html>not json</html>",
1057+
)
1058+
httpx_mock.add_response(
1059+
method="GET",
1060+
url="https://marketplace.infrahub.app/api/v1/collections/infrahub/vlan",
1061+
status_code=404,
1062+
json={"detail": "Collection not found"},
1063+
)
1064+
result = runner.invoke(app, ["show", "infrahub/vlan"])
1065+
1066+
assert result.exit_code == 2
1067+
assert "not valid JSON" in result.output

0 commit comments

Comments
 (0)