diff --git a/specs/02-resources.md b/specs/02-resources.md index 18c28a20..c4210e67 100644 --- a/specs/02-resources.md +++ b/specs/02-resources.md @@ -436,7 +436,7 @@ This keeps the body off the heap for `folder`/file-handle destinations — parit ## Exceptions thrown by parsing - `NoResultsException` — HTTP 204 with a typed `result_parser`. -- `NotFoundException` — HTTP 404, or a JSON-decode failure on a 404. +- `NotFoundException` — HTTP 404, or a JSON-decode failure on a 404. Carries `.code`: the server's machine-readable `errors['code']` when the envelope had one, else `None`. Today's values are `KNOWN_GOOD`, `NOT_STORED` (the platform knows the hash and deliberately never stored its bytes — it was declined as known-good at submission; resubmitting the file works), `DELETED` and `EXPIRED`. These strings are wire-frozen while the human message is prose, so `.code` is the supported way to branch — callers previously had to match on the message text to tell a deliberate withholding from a genuine miss. - `KnownGoodWithheldException` (a `NotFoundException` subclass) — HTTP 404 whose `errors` payload is a dict with `code == 'KNOWN_GOOD'`: the artifact is a known-good binary and its bytes are withheld by design. Carries `.sources` (the flagging known-good feeds, always a list of strings — normalised in the exception's constructor — and `[]` when none were named or the payload arrived in another shape). Any other 404 — a different code, a legacy list-shaped `errors`, or no `errors` at all — stays a plain `NotFoundException`. - `FailedInstanceException` — HTTP 422. - `UsageLimitsExceededException` — HTTP 429. diff --git a/specs/05-downstream-contract.md b/specs/05-downstream-contract.md index 5bbc461a..d27c4bbd 100644 --- a/specs/05-downstream-contract.md +++ b/specs/05-downstream-contract.md @@ -181,6 +181,8 @@ class TimeoutException(PolyswarmException): ... `KnownGoodWithheldException` is the 404 raised when a download is refused because the artifact is a known-good binary — the platform never stores or serves those bytes. It **subclasses `NotFoundException`** precisely so invariant 3 holds for existing consumers: code that already does `except NotFoundException:` keeps catching the refusal with no change, and only callers that want to distinguish "withheld by design" from a plain miss catch the subclass. It adds one attribute, `.sources` — the known-good feeds that flagged the hash (e.g. `['nsrl']`), `[]` when the server named none. The contract is only this: **always a list of strings**, whatever the envelope carried, so `for feed in exc.sources` needs no shape check. Which wire shapes are coerced, and which are dropped and logged, is `exceptions._normalise_sources`' business rather than a promise to consumers — the server sends a list of strings today. Note it is **not** normalised the same way as `ArtifactInstance.known_good_sources`, which is the same concept reached from the instance response: that one is sorted and de-duplicated, while `.sources` preserves the order the envelope carried and can repeat a feed. Don't assume parity between the two. The raw envelope stays reachable at `exc.request.errors` (`{'code': 'KNOWN_GOOD', 'known_good': True, 'sources': [...]}`). The artifact's metadata — the flagging feeds plus any scan data already collected — remains readable through the search / instance endpoints; only the bytes are withheld, and the instance's `KNOWN_GOOD` state/status is the signal for the typed refusal (there is no separate "withheld" field; a `NOT_STORED` instance has no bytes either, but 404s plainly — see below). +`NotFoundException.code` is the additive, non-typed half of the same idea, and the reason there is no second 404 subclass. Every 404 now carries the server's `errors['code']` verbatim (`None` when the envelope had none — an endpoint 404, a non-JSON body, an older server), so a caller can branch on `KNOWN_GOOD` / `NOT_STORED` / `DELETED` / `EXPIRED` without catching a new class and without matching on prose. That last part is the point: the codes are wire-frozen members of the server's `BountyState`, while the human message is prose the server may reword — and it did, for `NOT_STORED`, which used to read "the file does not exist" for bytes the platform deliberately never kept. `KnownGoodWithheldException` keeps its own class because it predates this and consumers already catch it; it sets `.code` to `'KNOWN_GOOD'` for uniformity. Adding a subclass per code would break invariant 3 for anyone catching the parent narrowly, so new codes get `.code` values, not new classes. + `FAVORITE_LIMIT` (a refused ruleset star: the team's favorite budget is spent) has **no typed exception** — deliberately, since no pre-existing consumer needs re-routing the way `NotFoundException` did. It surfaces as the generic 400 `RequestException`, and the machine-readable path is the raw envelope: `exc.request.errors == {'code': 'FAVORITE_LIMIT', 'favorites_used': N, 'favorites_limit': M}`. The counters are the same pair a successful toggle returns, so a caller can render the "budget full" state from either outcome. Pinned by the dual-transport respx suite `test/ruleset_favorite_respx_test.py`. **What "known-good" means on the server, as of artifact-index's two-predicate model** (its `specs/05`): the refusal fires on the server's *current understanding* — a catalogue entry exists for the sha256 **and** that entry's extension passes an executable allow-list — evaluated live on every request. Two consequences worth knowing as a consumer: the same download can start working again with no action on your part (the entry is deleted, or the policy narrows), and `ArtifactInstance.state` can report the new value **`NOT_STORED`** — a submission the server declined as known-good at the time whose hash is no longer currently known-good, so nothing was ever stored for it and a fresh submit of the same file works. `state` is a plain string here; the SDK does not enumerate it, so a new member needs no SDK release. diff --git a/src/polyswarm_api/core.py b/src/polyswarm_api/core.py index cbf0ffeb..f86a24fb 100644 --- a/src/polyswarm_api/core.py +++ b/src/polyswarm_api/core.py @@ -364,11 +364,17 @@ def _raise_for_status(response, request): # The raw ``sources`` payload goes in as-is — the exception normalises it # into the documented list-of-feed-names shape. errors = request.errors - if isinstance(errors, dict) and errors.get('code') == 'KNOWN_GOOD': + code = errors.get('code') if isinstance(errors, dict) else None + if code == 'KNOWN_GOOD': raise exceptions.KnownGoodWithheldException( request, request._result, sources=errors.get('sources'), ) - raise exceptions.NotFoundException(request, request._result) + # Every other 404 stays a plain NotFoundException, but now carries the code + # when the envelope had one. `NOT_STORED` in particular is a different fact + # from a miss — the platform knows the hash and deliberately never kept its + # bytes — and callers were previously forced to match on the message prose to + # see it. None when the server sent no code. + raise exceptions.NotFoundException(request, request._result, code=code) elif request.status_code == 422: raise exceptions.FailedInstanceException(request, request._result) else: diff --git a/src/polyswarm_api/exceptions.py b/src/polyswarm_api/exceptions.py index 9a2ebd6b..c626e306 100644 --- a/src/polyswarm_api/exceptions.py +++ b/src/polyswarm_api/exceptions.py @@ -34,7 +34,19 @@ class UsageLimitsExceededException(RequestException): class NotFoundException(RequestException): - pass + """404, with the server's machine-readable cause when the envelope carried one. + + ``code`` is the ``errors.code`` string — today ``'KNOWN_GOOD'`` (bytes withheld + by design), ``'NOT_STORED'`` (declined as known-good at submission, so nothing was + ever stored — a fresh submit works), ``'DELETED'`` or ``'EXPIRED'``. It is the + supported way to tell those apart: the human message is prose and may be reworded, + while these values are wire-frozen. ``None`` when the server sent no code — an + endpoint 404, a non-JSON body, or a server predating the field. + """ + + def __init__(self, request, *args, code=None): + super().__init__(request, *args) + self.code = code def _normalise_sources(sources): @@ -74,8 +86,8 @@ class KnownGoodWithheldException(NotFoundException): instance endpoints. """ - def __init__(self, request, *args, sources=None): - super().__init__(request, *args) + def __init__(self, request, *args, sources=None, code='KNOWN_GOOD'): + super().__init__(request, *args, code=code) # Known-good feeds that flagged the hash (e.g. ``['nsrl']``); always a list # of strings — normalised here, at the boundary, so the documented shape # holds whatever the envelope carried. Empty when the server named none. diff --git a/test/core_test.py b/test/core_test.py index f8fb076e..314f8091 100644 --- a/test/core_test.py +++ b/test/core_test.py @@ -276,6 +276,9 @@ def test_404_known_good_withheld_raises_subclass(self): # Existing ``except NotFoundException`` handlers must keep catching it. assert isinstance(ei.value, exceptions.NotFoundException) assert ei.value.sources == ['nsrl'] + # The subclass carries the same `.code` the plain 404 arm sets, so a caller can + # branch on the code uniformly whether or not it catches the subclass. + assert ei.value.code == 'KNOWN_GOOD' assert ei.value.request is req assert req.errors == body['errors'] @@ -330,7 +333,15 @@ def test_404_other_error_code_stays_plain_not_found(self): # Only the known-good code gets the subclass; every other 404 — including # a differently-coded or legacy list-shaped ``errors`` payload — stays a # plain NotFoundException. - for errors in ({'code': 'DELETED'}, ['not found'], None): + # ...but the code itself is still exposed on the plain exception, so a caller can + # branch without catching a new class and without matching on the message prose. + # NOT_STORED is the case that motivated it: the platform knows the hash and + # deliberately never stored its bytes, which is a different fact from a miss. + for errors, expected_code in (({'code': 'DELETED'}, 'DELETED'), + ({'code': 'NOT_STORED'}, 'NOT_STORED'), + ({'code': 'EXPIRED'}, 'EXPIRED'), + (['not found'], None), + (None, None)): req = PolyswarmRequest(api=_FakeApi(), method='GET', url='u', result_parser=_SampleResource) with pytest.raises(exceptions.NotFoundException) as ei: @@ -341,6 +352,7 @@ def test_404_other_error_code_stays_plain_not_found(self): req, ) assert not isinstance(ei.value, exceptions.KnownGoodWithheldException) + assert ei.value.code == expected_code def test_422_raises_failed_instance(self): req = PolyswarmRequest(api=_FakeApi(), method='POST', url='u',