Skip to content

feat(exceptions): expose the server's error code on NotFoundException - #326

Closed
mjbradford89 wants to merge 1 commit into
developfrom
dn-8558-known-good-refusal-messages
Closed

mjbradford89 wants to merge 1 commit into
developfrom
dn-8558-known-good-refusal-messages

Conversation

@mjbradford89

Copy link
Copy Markdown
Contributor

TL;DR

  • Every 404 now carries the envelope's errors['code'] as NotFoundException.code (None when the server sent none).
  • Lets a caller tell "bytes withheld by design" and "bytes were never stored" apart from a plain miss without matching on the message prose.
  • KnownGoodWithheldException keeps its own class and sets .code to KNOWN_GOOD — no behaviour change for anyone catching it.
  • Additive only. 5 files, +39/-7.

Why

A 404 from the platform API carries a machine-readable errors.code, and today the SDK reads exactly one value of it: KNOWN_GOOD, which routes to the KnownGoodWithheldException subclass. Every other code — NOT_STORED, DELETED, EXPIRED — was dropped on the floor, so a caller wanting to distinguish them had only str(exc).

That is a bad thing to depend on. The codes are wire-frozen members of the server's state enum; the human message is prose the server may reword — and it just did, for NOT_STORED, which previously read "the file does not exist" for bytes the platform deliberately never stored. A downstream consumer that had string-matched on that would have broken silently.

NOT_STORED is the case that motivated this: it means the platform knows the hash and intentionally never kept its bytes (it was declined as known-good at submission). Resubmitting the file works. That is materially different from "not found", and callers that cache verdicts need to tell them apart.

Why an attribute rather than a subclass

Adding a subclass per code would break invariant 3 of the downstream contract for anyone catching NotFoundException narrowly. KnownGoodWithheldException exists because it predates this and consumers already catch it; it now sets .code too, so the attribute reads uniformly whether or not the subclass is caught. New codes get .code values, not new classes — recorded in specs/05-downstream-contract.md.

Test plan

test_404_other_error_code_stays_plain_not_found now asserts .code across DELETED, NOT_STORED, EXPIRED, a legacy list-shaped errors, and no errors at all; the known-good test pins .code == 'KNOWN_GOOD' on the subclass.

Full suite: 231 passed.

Note for anyone running this locally: this repo's .venv currently has a stale non-editable install of polyswarm_api in site-packages that shadows src/. Running pytest without PYTHONPATH=src exercises the installed copy and produces 6 phantom failures unrelated to any change. Worth a uv pip install -e ..

Companion PRs

Same branch name, independently mergeable — no ordering dependency:

  • polyswarm-cli — renders a distinct status for NOT_STORED instead of falling through to "Assertion window closed". It does not consume .code; it reads the instance state that already ships.
  • A server-side PR reworks the NOT_STORED refusal message itself. errors.code is unchanged there, which is precisely why this attribute is the stable thing to branch on.

Every 404 now carries the envelope's errors['code'] as NotFoundException.code
(None when the server sent none — an endpoint 404, a non-JSON body, an older
server). Callers could previously only tell "bytes withheld by design" or "bytes
were never stored" from a plain miss by matching on the message prose, which the
server may reword; the codes are wire-frozen.

KnownGoodWithheldException keeps its own class — consumers already catch it — and
sets .code to KNOWN_GOOD so the attribute reads uniformly whether or not the
subclass is caught. New codes get .code values rather than new subclasses, which
would break narrow `except NotFoundException` consumers.
@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown

Review

Mechanically the change is right: _raise_for_status is the single 404 dispatch point (parse_response delegates to it for all non-2xx), the two JSONDecodeError-on-404 arms (core.py:317, core.py:344) correctly leave .code at None, which is exactly what the new docstring and specs/02-resources.md:439 promise. core.py/exceptions.py are Layer 1 and not unasync-processed, so no mirror regeneration is owed. Base is develop, and no version bump is correct here — per AGENTS.md the standing exception needs a sibling raising a polyswarm_api>= floor, and the PR body says the CLI companion reads the instance state, not .code.

Three things need action.

1. The PR's own premise is only asserted against a fabricated body

NOT_STORED is the motivating case, and it appears in zero cassettes — grep -rl NOT_STORED test/vcr/ is empty. Every .code assertion added here reads a dict this repo wrote. client_scan_test.py:1009-1012 already makes the argument against exactly that:

Every other assertion about the error envelope reads a body this repo fabricated, which pins what we think the server sends — a rename of the code string on the server side would leave those green.

That bites harder here than it did for KNOWN_GOOD, because the stated contract is "the codes are wire-frozen while the message is prose" — and nothing in the suite would notice if the server shipped NOTSTORED or NOT_STORED_ tomorrow. specs/04-testing.md §"The transport arm" says this arm is e2e-reachable, not respx-only: "the caller-visible outcome (the typed exception, its payload…) is the mapping." .code is that payload.

Two concrete gaps, one free and one worth a re-record:

  • Free, no re-record. test_known_good_lifecycle (client_scan_test.py:1063) already catches a real plain 404 from known_good_get after the delete, and that cassette's envelope carries no code. One line — assert ei.value.code is None — pins the "server sent no code → None" arm against a real response instead of a fabricated None. Same for the async twin.
  • Needs a re-record. The same test deletes the known-good entry at line 1058 for a sha that was declined as known-good at submission — which per specs/05-downstream-contract.md:188 is precisely how an instance reaches NOT_STORED. A v3api.download(out_dir, sha) after that delete should produce the real NOT_STORED 404, and asserting ei.value.code == 'NOT_STORED' there is the only thing in this PR that would actually catch the server renaming the string. Record per AGENTS.md (rm test/vcr/test_known_good_lifecycle.vcr, re-run against a fresh stack), both transports.

2. specs/01-architecture.md not updated

Two passages describe the arm this PR changed and still say it reads errors only for KNOWN_GOOD:

  • :215 — "The 404 arm also reads the extracted errors payload: a dict carrying code == 'KNOWN_GOOD' raises … KnownGoodWithheldException instead."
  • :270 — the canonical exception-model list: "404 → NotFoundException — or KnownGoodWithheldException … the exception exposes the flagging feeds as .sources." No mention of .code.

AGENTS.md: "Update the spec in the same PR as the code change." :270 is where a reader goes for the exception model, so it's the one that matters.

3. specs/02-resources.md:313 now contradicts specs/05-downstream-contract.md:184

Untouched by this PR, :313 reads: "state == 'NOT_STORED' … but its download 404s plainly, without the KNOWN_GOOD code." After this change that 404 is no longer plain — it carries code == 'NOT_STORED', which is what the new paragraph at 05:184 advertises as the thing to branch on. Worth ending the sentence with "…without the KNOWN_GOOD code — it carries NOT_STORED instead" so the two specs don't disagree about the same response.


Minor, take or leave: KnownGoodWithheldException.__init__ exposing code as an overridable kwarg lets a caller construct the subclass with a code that contradicts its class. code is fixed by the class's meaning, so setting self.code = 'KNOWN_GOOD' in the body (or super().__init__(request, *args, code='KNOWN_GOOD')) avoids widening the public signature for nothing.

@sbneto

sbneto commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

When the platform refuses a download or rescan for a hash whose bytes it deliberately never stored, it said "the file does not exist" — the same words a genuinely absent artifact gets. The reporter read that as data loss and spent an investigation hunting a retention bug across 297 files in 47 container images. The server side splits the refusal on the row's persisted state so it names the cause, leaving errors.code identical on both arms; this PR is the SDK half, exposing that code on NotFoundException so a caller can branch without matching on prose. 3 PRs on dn-8558-known-good-refusal-messages; 10 files, +170/-13 across the set.

Severity: 0 HIGH · 3 MODERATE · 5 LOW. Prior feedback: 9 checked · 9 open.
Objective: met, with gaps — names the cause for the ticket's 297-file bucket, not its 10-file one.

  • missing: "should search return a record with polyscore=None and last_scanned=None at all?" → F3

Fixes are proposed, not applied; nothing was run.

Cross-repo coordination

Member PR State Role
artifact-index polyswarm/artifact-index#1978 open API (producer)
polyswarm-api #326 closed, unmerged SDK ← you are here
polyswarm-cli polyswarm/polyswarm-cli#273 closed, unmerged; branch deleted CLI

Merge order: artifact-index#1978 → polyswarm-cli#273 (§14: API is the contract). This PR is unordered — nothing in the set consumes .code, so it neither gates nor is gated.

Contracts crossing the set: errors.code on the 404 refusal — producer artifact-index, consumers this repo (branches on KNOWN_GOOD only) and polyswarm-cli (reads no code) → F2, on the server side, moves this value for one cohort. It is invisible to _raise_for_status, which tests only KNOWN_GOOD.

Coherence: fixes are independent; no cross-repo adjustment needed.

Gaps: polyswarm-cli's branch is gone from origin — head 142578d survives only as refs/pull/273/head. This PR's branch is still on origin at 83ebd78, so it reopens as-is.

Findings (round 1)

Every finding below is work for this change set; each entry's Lands in: names the repo whose PR carries the fix, on the branch name every member already shares.

  • [LOW] F5. specs/01-architecture.md still describes the 404 arm as reading errors only for KNOWN_GOOD and omits .code from the canonical exception model (specs/01-architecture.md:270, and the dispatch sentence at :215), while specs/02-resources.md still says a NOT_STORED download "404s plainly, without the KNOWN_GOOD code" — which the new .code contradicts (specs/02-resources.md:313). AGENTS.md requires the spec to move with the code. Fix (untested): add .code to the :270 list and the :215 sentence, and end :313 with "— it carries NOT_STORED instead".
  • [LOW] F6. KnownGoodWithheldException.__init__ takes code as an overridable keyword, so a caller can construct the subclass with a code contradicting its own class (src/polyswarm_api/exceptions.py:89). Fix (untested): drop the kwarg and pass code='KNOWN_GOOD' to super().__init__ in the body.

elsewhere: F1, F2, F4, F8 → polyswarm/artifact-index#1978 · F3, F7 → polyswarm/polyswarm-cli#273

Outstanding review feedback

Status Raised The ask Disposition
not addressed #326, round 1 specs/01-architecture.md not updated for .code → F5
not addressed #326, round 1 specs/02:313 now contradicts specs/05:184 → F5
not addressed #326, round 1 code kwarg widens the subclass signature → F6
open — not a defect #326, round 1 Asks for a cassette pinning a real NOT_STORED 404, since every .code assertion reads a fabricated body. Nothing in the diff answers it, and the free half — one assert ei.value.code is None against the real 404 already recorded in test_known_good_lifecycle — is worth taking. But the harm it predicts cannot occur: artifact-index asserts the literal envelope twice in this same change set (test/views/v3/known_good_test.py:178 and :199), so a server-side rename fails there first, and the extraction here is one value-agnostic errors.get('code'). No finding drafted. —

Standards conformity

Set-level. §14 delivery order ✓ — the server PR ships no capability needing SDK or CLI support, since errors.code is byte-identical on both its arms and only prose moves, so "API, SDK and CLI land in one change set" does not bind a message rewording. Rule 6 name identity ◐ — the two surviving branches are byte-identical and tag-safe, but polyswarm-cli's is deleted from origin, so the set can no longer resolve companion code by name → F1. ## Requires ✓ — correctly absent; nothing in the set gates anything else.

Change-level. §15 ✗ → F5.

Project-level. §16 ✓ — no version bump is owed: no sibling in this set adopts .code (the CLI reads ArtifactInstance.state), so the standing source-resolved exception does not apply and the bump belongs to the release step.

Clean: §2, §16. Non-clean: §15 ✗. Not applicable: §3–§14, §17 — a client library, so no queues, storage, AKM, charts, clusters or request paths.

@sbneto

sbneto commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

When the platform refuses a download or rescan for a hash whose bytes it deliberately never stored, it said "the file does not exist" — the same words a genuinely absent artifact gets, which sent the reporter hunting a retention bug across 297 files in 47 container images. The server now answers that row first, above deleted and expired, and names the cause; this PR is the SDK half, exposing the machine-readable errors.code on NotFoundException so a caller can branch without matching on prose. 3 PRs on dn-8558-known-good-refusal-messages; 10 files, +242/-16 across the set.

Severity: 0 HIGH · 1 MODERATE · 6 LOW. Prior feedback: 13 checked · 10 open.
Objective: met, with gaps — names the cause for the ticket's 297-file bucket, not its 10-file one.

  • missing: "should search return a record with polyscore=None and last_scanned=None at all?" → F1

Fixes are proposed, not applied; nothing was run.

Cross-repo coordination

Member PR State Role
artifact-index polyswarm/artifact-index#1978 open, head moved since round 1 API (producer)
polyswarm-api #326 closed, unmerged — unchanged SDK ← you are here
polyswarm-cli polyswarm/polyswarm-cli#273 closed, unmerged; branch still deleted — unchanged CLI

Merge order: artifact-index#1978 → polyswarm-cli#273 (§14: API is the contract). This PR is unordered — nothing in the set consumes .code, so it neither gates nor is gated.

Contracts crossing the set: errors.code on the 404 refusal. The server PR now moves that value for one cohort (EXPIRED → NOT_STORED) and ships a consumer audit for it. It is invisible here: _raise_for_status branches on KNOWN_GOOD alone and passes every other 404 through as a plain NotFoundException, so nothing in this PR changes as a result.

Coherence: fixes are independent; no cross-repo adjustment needed.

Gaps: polyswarm-cli's branch is gone from origin — head 142578d survives only as refs/pull/273/head. This PR's branch is intact on origin at 83ebd78, so it reopens as-is.

Findings (round 2)

Every finding below is work for this change set; each entry's Lands in: names the repo whose PR carries the fix, on the branch name every member already shares.

  • [LOW] F4. specs/01-architecture.md still describes the 404 arm as reading errors only for KNOWN_GOOD and omits .code from the canonical exception model (specs/01-architecture.md:270, and the dispatch sentence at :215), while specs/02-resources.md still says a NOT_STORED download "404s plainly, without the KNOWN_GOOD code" — which the new .code contradicts (specs/02-resources.md:313). AGENTS.md requires the spec to move with the code. Fix (untested): add .code to the :270 list and the :215 sentence, and end :313 with "— it carries NOT_STORED instead". Lands in: polyswarm-api
  • [LOW] F5. KnownGoodWithheldException.__init__ takes code as an overridable keyword, so a caller can construct the subclass with a code contradicting its own class (src/polyswarm_api/exceptions.py:89). Fix (untested): drop the kwarg and pass code='KNOWN_GOOD' to super().__init__ in the body. Lands in: polyswarm-api

elsewhere: F1, F6 → polyswarm/polyswarm-cli#273 · F2, F3, F7 → polyswarm/artifact-index#1978

Outstanding review feedback

Status Raised The ask Disposition
not addressed #326, round 1 specs/01 not updated; specs/02:313 contradicts specs/05 → F4
not addressed #326, round 1 code kwarg widens the subclass signature → F5
open — not a defect #326, round 1 Asks for a cassette pinning a real NOT_STORED 404, since every .code assertion reads a fabricated body. Unchanged, and the free half — one assert ei.value.code is None against the real 404 already recorded in test_known_good_lifecycle — is still worth taking. But the harm it predicts still cannot occur: artifact-index asserts the literal envelope in this same change set, so a server-side rename fails there first, and the extraction here is one value-agnostic errors.get('code'). No finding drafted. —

Standards conformity

Audited in round 1 for all three members; that block stands. No new change-level violation in this PR — it is byte-identical to round 1.

Set-level rows unchanged: §14 ✓, Rule 6 ◐ (polyswarm-cli's branch is deleted from origin, so the set can no longer resolve companion code by name), ## Requires ✓. The server PR's body now states the non-dependency explicitly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants