Skip to content

Commit 7bdb34c

Browse files
committed
fix(purl): define strict response semantics
1 parent c30496c commit 7bdb34c

7 files changed

Lines changed: 48 additions & 13 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "socketdev"
7-
version = "3.4.0"
7+
version = "3.4.2"
88
requires-python = ">= 3.9"
99
dependencies = [
1010
'requests',

socketdev/exceptions.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,18 @@ class APIPartialResponse(APIFailure):
9191
to diff the response themselves.
9292
9393
The ``missing`` attribute holds the requested purls that were absent from the
94-
response (the HTTP call itself succeeded, so there is no status code).
94+
response (the HTTP call itself succeeded, so there is no status code). A missing
95+
row may reflect pending analysis, a malformed or unknown purl, or a response contract
96+
failure, so blindly retrying is not guaranteed to succeed. Callers that need a bounded
97+
wait should use ``poll=True``; callers that need omission reasons should request
98+
``alerts=True`` and/or ``purl_errors=True``.
9599
"""
96100

97101
def __init__(self, *args, missing=None):
98102
super().__init__(*args)
99103
self.missing = list(missing or [])
104+
105+
def is_transient_error(self) -> bool:
106+
# The HTTP request completed successfully, and the omission reason may be
107+
# permanent. Server-side polling is the explicit bounded retry mechanism.
108+
return False

socketdev/purl/__init__.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,15 @@ def post(
4646
unresolved inputs, so callers can distinguish "no data yet" from "clean".
4747
purl_errors: when ``True`` (``→ purlErrors``), the server includes per-purl
4848
error rows for malformed/unresolvable inputs. ``None`` omits the param.
49-
strict: client-side guard. When ``True``, compares the ``purl`` of each
50-
requested component against the ``inputPurl``/``purl`` of the returned
51-
rows and raises :class:`~socketdev.exceptions.APIPartialResponse` (with a
52-
``missing`` list) if any requested purl is absent from the response. This
53-
surfaces partial batches even without ``alerts=True``. Only components that
54-
carry a ``purl`` string are checked.
49+
strict: client-side guard. When ``True``, compares the exact ``purl`` string
50+
of each requested component against the returned ``inputPurl`` (or the
51+
``purl`` fallback). The API defines ``inputPurl`` as the original,
52+
unmodified input before server normalization, so canonicalized ``purl``
53+
values do not cause false omissions. Raises
54+
:class:`~socketdev.exceptions.APIPartialResponse` (with a ``missing``
55+
list) if any requested purl is absent from the response. This surfaces
56+
partial batches even without ``alerts=True``. Only components that carry
57+
a ``purl`` string are checked.
5558
**kwargs: forwarded verbatim into the query string (back-compat passthrough for
5659
any params not yet promoted to first-class arguments).
5760
@@ -127,9 +130,11 @@ def post(
127130
def _raise_on_missing(components: list, results: list) -> None:
128131
"""Raise APIPartialResponse if any requested component purl is absent from results.
129132
130-
Only components exposing a ``purl`` string are checked; the batch API echoes the
131-
request identifier back as ``inputPurl`` (falling back to ``purl``), including
132-
under ``value`` for typed ``purlError`` stream records.
133+
Only components exposing a ``purl`` string are checked. The batch API contract
134+
defines ``inputPurl`` as the original, unmodified input string before server-side
135+
normalization, so matching it exactly preserves the caller's identity even when
136+
the response's canonical ``purl`` differs. ``purl`` is retained as a fallback,
137+
and typed ``purlError`` stream records carry ``inputPurl`` under ``value``.
133138
"""
134139
requested = [
135140
c["purl"]

socketdev/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "3.4.0"
1+
__version__ = "3.4.2"

tests/unit/test_all_endpoints_unit.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,21 @@ def test_purl_post_strict_passes_when_complete(self):
528528
)
529529
self.assertEqual(len(result), 1)
530530

531+
def test_purl_post_strict_matches_original_input_before_normalization(self):
532+
"""strict=True matches exact inputPurl even when the canonical purl differs."""
533+
requested_purl = "pkg:npm/%40scope/pkg@1.0.0"
534+
server_rows = [
535+
{
536+
"inputPurl": requested_purl,
537+
"purl": "pkg:npm/@scope/pkg@1.0.0",
538+
}
539+
]
540+
541+
self.sdk.purl._raise_on_missing(
542+
[{"purl": requested_purl}],
543+
server_rows,
544+
)
545+
531546
# Quota endpoints
532547
def test_quota_get_unit(self):
533548
"""Test quota retrieval."""

tests/unit/test_exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
APIInsufficientPermissions,
2626
APIInsufficientQuota,
2727
APIOrganizationNotAllowed,
28+
APIPartialResponse,
2829
APIResourceNotFound,
2930
APITimeout,
3031
)
@@ -51,6 +52,11 @@ def test_connection_level_classes_are_transient(self):
5152
self.assertTrue(APIConnectionError().is_transient_error())
5253
self.assertTrue(APIBadGateway().is_transient_error())
5354

55+
def test_partial_response_is_not_transient(self):
56+
error = APIPartialResponse("incomplete", missing=["pkg:npm/missing@1.0.0"])
57+
self.assertFalse(error.is_transient_error())
58+
self.assertEqual(error.missing, ["pkg:npm/missing@1.0.0"])
59+
5460
def test_bad_gateway_carries_502_by_default(self):
5561
self.assertEqual(APIBadGateway().status_code, 502)
5662

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)