Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion specs/02-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions specs/05-downstream-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions src/polyswarm_api/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 15 additions & 3 deletions src/polyswarm_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 13 additions & 1 deletion test/core_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand Down Expand Up @@ -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:
Expand All @@ -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',
Expand Down
Loading