Skip to content

A body-less 304 dead-letters — make "not modified" a first-class outcome before conditional GET is usable #17

Description

@gregoryfoster

Flagged as a follow-on by #10 and by cannobserv#271; filed so it is decided
before an issuer sends its first validator header rather than discovered by the
first one that does.

Why

A body-less 304 Not Modified is today a dead-letter outcome. _raise_for_status
(src/worker/handler.py) branches on is_2xx,
not is_success, deliberately — a 304 reports is_success while carrying an empty
body, and storing it would content-address the empty string. Everything non-2xx that
is not 5xx/408/429 becomes a PermanentFetchError with
reason=FailureReason.HTTP_STATUS, so the command closes as
fetch_failed / http_status / status_code=304 and the frame is copied to
content.fetch.dlq.

That is correct today, because nothing sends a conditional request: a 304 arriving
unbidden really is an origin behaving oddly. It stops being correct the moment an
issuer replays a validator, because then the 304 is the single most useful answer
the origin can give
— "your bytes are current, here is nothing, you are done" — and
Replicator reports it as a failure with a 304 attached and dead-letters the command.

Both halves of the conditional-GET seam are otherwise in place or in flight:

docs/contracts/content-fetch-issuer-contract.md currently carries an explicit "do not
attempt conditional GET yet" warning naming all three. This issue is what removes it.

The decision

Two shapes were floated (#10, cannobserv#271). Recommending the first:

A. reason="not_modified", terminal=True, on the existing fetch_failed fact.

  • The failure taxonomy already has a row for the body-less 304 (http_status); this
    splits one row into two rather than inventing a category.
  • reason is a plain str, not a Literal, precisely so a producer can add a token
    without crashing an older extra="ignore" consumer. The vocabulary is designed to
    grow this way.
  • Consumers already branch on terminal first and treat an unknown reason as
    opaque, so an issuer that has not been taught the token still does the right thing:
    closes the pending entry, records a reason it does not recognise. That is a much
    better default than a payload type it cannot dispatch at all.
  • No new stream wiring, no new dispatch arm, no from_wire table entry.

B. A dedicated content_unchanged fact. Cleaner naming — "unchanged" genuinely is
not a failure — but it costs every consumer a new dispatch arm for an outcome that is
structurally identical to the others on this stream: no blob is coming, close the
entry. It also needs a co-core model, a ChangeEventPayload union member, an
idempotency_key rule, and a from_wire entry, none of which buy an issuer anything
it cannot get from a token.

The honest cost of A is that fetch_failed is then carrying a non-failure. Worth
stating in the model docstring and in the contract: the event's real meaning is "this
command will not produce a blob", and terminal is the field that matters. If that
naming later becomes untenable, B stays available and additive.

Scope, once decided

  • FailureReason.NOT_MODIFIED in src/core/errors.py, raised at the 304 site in
    _raise_for_status — named at the raise site, never recovered from a message string,
    per the convention.
  • The reporter carries it through unchanged; status_code=304 still rides along.
  • Whether a 304 should still be dead-lettered is the sub-decision worth making
    explicitly. A successful conditional GET is not operator-actionable, so copying every
    one to content.fetch.dlq would fill the DLQ with routine successes and devalue it
    as an operator surface. Recommend: fact yes, DLQ no — which makes this the first
    outcome to close a command without a DLQ entry, and therefore worth its own test.
  • Contract doc: a taxonomy row, the reason token table, removal of the
    "do not attempt conditional GET yet" warning, and MUST-6's silence accounting.
  • co-core companion issue to document the token in FetchFailedEvent's docstring — the
    canonical home of the vocabulary (cf. cannobserv#277, which struck wrong_payload_type
    from it).

Sequencing

Does not block #10 (shipped) or #11 (the request options are useful for User-Agent and
timeouts independently of validators). It does gate the contract's warning coming
out, and it should land before any issuer is told conditional GET is available.

Refs

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions