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
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 onis_2xx,not
is_success, deliberately — a 304 reportsis_successwhile carrying an emptybody, and storing it would content-address the empty string. Everything non-2xx that
is not 5xx/408/429 becomes a
PermanentFetchErrorwithreason=FailureReason.HTTP_STATUS, so the command closes asfetch_failed/http_status/status_code=304and the frame is copied tocontent.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:
blob_availablenow returnsetagandlast_modifiedverbatim, so an issuer can store validators without Replicatorholding any per-URL history.
ContentFetchCommand.headers(co-core ≥ 0.7.3,cannobserv#272) replays them as
If-None-Match/If-Modified-Since.docs/contracts/content-fetch-issuer-contract.mdcurrently carries an explicit "do notattempt 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 existingfetch_failedfact.http_status); thissplits one row into two rather than inventing a category.
reasonis a plainstr, not aLiteral, precisely so a producer can add a tokenwithout crashing an older
extra="ignore"consumer. The vocabulary is designed togrow this way.
terminalfirst and treat an unknownreasonasopaque, 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.
from_wiretable entry.B. A dedicated
content_unchangedfact. Cleaner naming — "unchanged" genuinely isnot 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
ChangeEventPayloadunion member, anidempotency_keyrule, and afrom_wireentry, none of which buy an issuer anythingit cannot get from a token.
The honest cost of A is that
fetch_failedis then carrying a non-failure. Worthstating in the model docstring and in the contract: the event's real meaning is "this
command will not produce a blob", and
terminalis the field that matters. If thatnaming later becomes untenable, B stays available and additive.
Scope, once decided
FailureReason.NOT_MODIFIEDinsrc/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.
status_code=304still rides along.explicitly. A successful conditional GET is not operator-actionable, so copying every
one to
content.fetch.dlqwould fill the DLQ with routine successes and devalue itas 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.
reasontoken table, removal of the"do not attempt conditional GET yet" warning, and MUST-6's silence accounting.
FetchFailedEvent's docstring — thecanonical home of the vocabulary (cf. cannobserv#277, which struck
wrong_payload_typefrom 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
docs/contracts/content-fetch-issuer-contract.md