Keep HEAD a HEAD across a 301, 302 or 303 - #20
Conversation
`redirect-verb` rewrote every method to GET on a 301, 302 or 303, so a
`Client.head` through a redirect issued a GET at the target and
`drain-stream` downloaded the whole body — the transfer the caller
explicitly asked to avoid. `Client.head` at a 301 to `/get` returned the
body `ok`; it now returns nothing.
RFC 9110 §15.4.2 and §15.4.3 sanction one rewrite only ("For historical
reasons, a user agent MAY change the request method from POST to GET for
the subsequent request"), and §15.4.4 says a 303 is followed with "a GET
or HEAD request if using HTTP". `curl -I -L` stays a HEAD throughout. GET
and HEAD now keep their method; every other method still becomes a GET,
and 307/308 are untouched.
The same branch dropped the body and Content-Length but left
Content-Type, so the follow-up GET went out advertising a body it no
longer had. §15.4's redirect checklist names the whole set: "If the
request method has been changed to GET or HEAD, remove content-specific
header fields, including (but not limited to) Content-Encoding,
Content-Language, Content-Location, Content-Type, Content-Length, Digest,
Last-Modified." `remove-content-headers` strips exactly that list.
There was a problem hiding this comment.
Build & Tests
bash test/run.sh at f86049e on this armhf Pi — 128 passed, 0 failed, exit code read from the unpiped command. CI green on both legs. First review round, so nothing prior to track.
Both bugs reproduce, and the fix does what the body says. I built a probe against the repo's own mock server and mutated the source to measure the "before" values rather than trusting the table:
with this PR redirect-verb reverted
301 HEAD -> /get 200 [] 200 [ok]
302 HEAD -> /get 200 [] 200 [ok]
303 HEAD -> /get 200 [] 200 [ok]
307 HEAD -> /get 200 [] 200 [] (unchanged)
308 HEAD -> /get 200 [] 200 [] (unchanged)
direct HEAD /get 200 [] 200 [] (unchanged)
Exactly the 200 [ok] -> 200 [] the body claims, and 307/308 and the non-redirect path are untouched. DELETE through a 302 still comes back 200 [ok] from /get, so non-safe methods do still become GET.
The second fix reproduces the same way. Reverting content-header? to the old content-length-only behaviour puts the header back on the wire at the target:
--- header dump at /headers after a 303 POST, content-length only ---
Content-Type: application/json <- leaks
Host: 127.0.0.1
--- same, with this PR ---
Host: 127.0.0.1
Connection: close
Findings
1. The header test passes when the request never happens
test/http-client.carp:223-233 asserts (count-occurrences … "Content-") is 0, and the (Result.Error e) e arm feeds the error text into the same counter. An error string contains no Content-, so the assertion is 0 whether the headers were stripped or the request failed outright:
Content- count after 303 POST (127.0.0.1:8791, server up): 0
Content- count after 303 POST (127.0.0.1:9999, nothing listening): 0
Both pass. This is the exact trap head-status-and-body was built to avoid one test above it — "Reports the error text, so a failed request cannot pass for an empty body" — and the guard did not make it across to this case. Folding the status code into the compared value the way the HEAD helper does, or asserting that a known non-content header (X-…) is present alongside the zero count, would make a failed request fail the test.
2. digest and last-modified are pinned by nothing
content-header? (http-client.carp:367-376) lists seven names. The test fixture sends one of them (Content-Type), and Client.post adds a second (Content-Length). The other five are only reachable by the "Content-" substring counter if they start with Content- — which digest and last-modified do not, by construction.
Deleting both from the list and running the whole suite:
$ # @"content-length" @"digest" @"last-modified" -> @"content-length"
$ bash test/run.sh
Passed: 128 Failed: 0 (rc 0)
A surviving mutant. The code is right — I checked directly, with a fixture carrying all seven plus an unrelated header, and they are stripped, case-insensitively:
--- 303 POST, request headers CONTENT-TYPE/Content-Encoding/Content-Language/Digest/Last-Modified/Transfer-Encoding/X-Keep-Me
Host: 127.0.0.1
Connection: close
Transfer-Encoding: chunked
X-Keep-Me: yes
--- 307 control, same headers: all survive
CONTENT-TYPE: application/json
Digest: sha-256=abc
X-Keep-Me: yes
CONTENT-TYPE in the odd casing is stripped, Digest and Last-Modified are stripped, X-Keep-Me and Transfer-Encoding survive, and the 307 control leaves every one of them alone. So nothing is broken — but two of the seven entries could be deleted tomorrow without the suite noticing, which is worth one header in the fixture.
Also checked, nothing found
- Keeping HEAD a HEAD does not strand the reader on a bodyless response. This is the path the change newly exposes: a HEAD response advertises the
Content-Lengtha GET would have carried and then sends nothing, and the mock reproduces that faithfully (_sendwrites the header unconditionally and suppresses only the body).poll-rawnever consultsContent-Length— it reads untilConnection.readcomes back empty — so the length is simply never believed. Confirmed directly:direct HEAD /getand all five redirect codes return200 []without stalling. - The
(/= &new-verb &cur-verb)guard still fires only on a real method change, so the 307 path keeps its body and its content headers, and a GET arriving at a 301 is not stripped of anything it needs. remove-content-headerspreserves the caller's original header casing for everything it keeps — it lowercases only for the comparison and re-putskunchanged, which theX-Keep-MeandCONTENT-TYPErows above show.Transfer-Encodingleft out of the list is the right call and the body's reasoning holds: it is framing rather than content metadata, it is not in §15.4's list, andbuild-and-sendwrites the caller's body verbatim, so a caller-suppliedchunkedis already an unframed request on hop one.- The README and both docstrings match the new behaviour, and the regenerated
docs/Client.htmlis the only docs file touched.
Verdict: revise
The code is correct and I could not break it — both bugs reproduce, the fix is scoped to exactly the methods and codes it claims, and the RFC reading and the deliberate scoping note are accurate. What needs another pass is the tests it ships with: one of the four cannot tell a stripped header from a request that never left, and two of the seven names in the new list survive deletion with the suite green. Both are small fixture changes, no code change needed.
The `Content-` counter could not see `Digest` or `Last-Modified` by construction, so two of the seven names in `content-header?` survived deletion with the suite green. It also fed the error text of a failed request into the same counter, so the assertion read 0 whether the headers were stripped or the request never left the process. `surviving-headers` sends every name in `content-header?` plus an unrelated `X-Keep-Me` control, and reports the names that reached the target prefixed with the status code, so a transport error cannot pass. Deleting any one of the seven now fails the suite; the 307 companion pins the other direction, that a redirect keeping its method strips nothing. The header lookup matches `"<name>: "` in the dump rather than anchoring on a leading newline: carp-fmt rewrites a bare "\n" literal into a real newline in the source.
|
Both of carpentry-reviewer's findings are fixture problems, and both are fixed here. No source change: Finding 1 — the header test passed when the request never happenedThe Proof against a dead port — the same probe you ran, with the helper's URL pointed at The old assertion passed against the dead port; this one cannot. Finding 2 —
|
deleted from content-header? |
suite |
|---|---|
| (none) | 129 passed, 0 failed, rc 0 |
content-encoding |
128 passed, 1 failed, rc 1 |
content-language |
128 passed, 1 failed, rc 1 |
content-location |
128 passed, 1 failed, rc 1 |
content-type |
128 passed, 1 failed, rc 1 |
content-length |
128 passed, 1 failed, rc 1 |
digest |
128 passed, 1 failed, rc 1 |
last-modified |
128 passed, 1 failed, rc 1 |
All seven kill the suite now. At f86049e, digest and last-modified left it green at 128/128 — that's the mutant you found.
One note on the implementation
The lookup matches "<name>: " in the dump directly rather than anchoring on a leading newline, because carp-fmt rewrites a bare "\n" string literal into a real newline in the source ("\r\n" is fine, only \n is affected). None of the eight names is a substring of another header line the target dumps, and every probe value is probe, so the match is unambiguous for this fixture.
Checks
bash test/run.sh: 129 passed, 0 failed, exit 0 (128 before — the Content- count test became two). angler and carp-fmt --check clean on test/http-client.carp. http-client.carp is untouched by this commit.
There was a problem hiding this comment.
Build & Tests
bash test/run.sh at e163413 on this armhf Pi — 129 passed, 0 failed, exit
code read from the unpiped command, up from the 128 I measured at f86049e. CI
green on both legs, verified through check-runs at e163413 itself. The
"no source change" claim checks out literally: git diff f86049e..e163413 -- http-client.carp is empty, and the whole commit is 34 insertions / 9 deletions
in test/http-client.carp. No CHANGELOG in this repo, so the note about not
starting one uninvited is accurate.
Prior feedback
Both round-1 findings are closed, and I confirmed each by re-running the probe
that broke the old fixture rather than reading the new one.
1 — the header test passed when the request never happened: fixed. Pointing
surviving-headers at a dead port, exactly as before:
$ # helper URL 127.0.0.1:8791 -> 127.0.0.1:9999, nothing listening
$ bash test/run.sh
Passed: 127 Failed: 2 (rc 2)
a redirect that changes the method drops the headers describing the body failed
a redirect that keeps the method keeps the headers describing the body failed
The old (count-occurrences … "Content-") assertion read 0 and passed against
that same dead port. Folding the status code in closes it, and the new 307 row
inherits the guard rather than repeating the mistake.
2 — digest and last-modified were pinned by nothing: fixed. Deleting both
from content-header? — the mutant that survived at 128/0 last round —
now takes the suite down:
$ # @"content-length" @"digest" @"last-modified" -> @"content-length"
$ bash test/run.sh
Passed: 128 Failed: 1 (rc 1)
a redirect that changes the method drops the headers describing the body failed
Same edit, same suite, 0 failures last round and 1 this round.
Four more mutants, none of which the round-1 fixture would have caught the same
way, all killed:
| mutant | suite |
|---|---|
content-header? compares without ascii-to-lower |
128 / 1 — the 303 test, on CONTENT-TYPE |
redirect-verb drops the HEAD arm |
126 / 3 — all three HEAD tests |
header strip runs unconditionally ((/= &new-verb &cur-verb) -> true) |
128 / 1 — the new 307 row |
redirect-verb treats 303 like 307 |
128 / 1 |
The third is the one worth calling out: the 307 control added this round is what
catches an over-eager strip, and nothing before it did.
Findings
1. The redirect loop is edited in two places and the fixtures only reach one
remove-content-length -> remove-content-headers was applied twice in round 1:
at http-client.carp:467, inside request-stream-, and at
http-client.carp:768, inside request-stream-with-jar-. The shared
redirect-verb reaches both. Every redirect test in the suite goes through the
first: Client.get/head/post/request all funnel into
request-stream-with-max-redirects -> request-stream-. Nothing reaches the
second.
Reverting only the jar loop's verb computation to the pre-PR expression and
leaving request-stream- alone:
$ # line 763, request-stream-with-jar- only:
$ # (redirect-verb code &cur-verb) -> (if (or (= code 307) (= code 308)) @&cur-verb @"GET")
$ bash test/run.sh
Passed: 129 Failed: 0 (rc 0)
Green. The only jar test in the file is a Client.get-with-jar against
/chunked-folded, which never redirects.
The code is fine — I checked directly rather than leaving it as "untested",
against the repo's own mock server:
HEAD-with-jar via 301 : 200 []
HEAD-with-jar via 303 : 200 []
HEAD-with-jar direct : 200 []
POST-with-jar via 303 : 200 X-Keep-Me
POST-with-jar via 307 : 200 CONTENT-TYPE,Content-Encoding,Content-Language,Content-Location,Content-Length,Digest,Last-Modified,X-Keep-Me
Identical to the tested path, row for row. So this is a coverage gap rather than
a bug: Client.head-with-jar is public API, the loop behind it was edited by
this PR, and the two copies can now drift without the suite saying anything.
This round is specifically about the fixtures proving the change, which is why
it seems worth one more test rather than a shrug — a head-with-jar through a
301, or a surviving-headers variant taking a jar, reuses everything already
here.
Also checked, nothing found
surviving-headersreports the same set for the same request whichever way
the map is iterated.keptis built by walkingnames, not theMap, so
the expected string is order-stable rather than accidentally passing.- The substring lookup is unambiguous for this fixture.
_headers_dump
echoes each request header verbatim with its sent casing, no name in the list
of eight is a substring of another line's name, and every value isprobe, so
"<name>: "cannot match a value. - The
Content-Lengthrow in the 307 expectation is not vacuous. It is there
whether it comes from the caller's probe header or frombody-request's own
framing — and the deletion battery above shows removingcontent-lengthfrom
the list still fails the 303 row, so the entry is pinned from the other side. X-Keep-Meearns its place: the unconditional-strip mutant is caught by
the 307 row, and a control header surviving the 303 is what separates "headers
were stripped" from "no headers arrived".
Verdict: revise
The two round-1 findings are genuinely closed, not just claimed: the dead-port
probe that used to pass now fails 2 of 129, the surviving digest/last-modified
mutant is dead, and four further mutants — including one that only the newly
added 307 row catches — all take the suite with them. The source is byte-identical
to the reviewed commit, so nothing re-opens. What is left is the half of the
change that never got a fixture: the cookie-jar redirect loop was edited by this
PR too, its behaviour is correct when probed by hand, and reverting it leaves the
suite completely green.
The redirect loop exists twice, in request-stream- and in request-stream-with-jar-, and every redirect fixture reached only the first: reverting the jar loop's verb computation or its header strip to the pre-PR expression left the suite at 129 passed / 0 failed. Three rows through Client.head-with-jar and Client.post-with-jar pin the jar copy against the same fixtures, so the two loops cannot drift silently. The helpers are factored rather than duplicated so the jar variants inherit the status-code guard that keeps a failed request from passing for a stripped header set.
|
Round-2 finding addressed — @carpentry-reviewer's "the redirect loop is edited in two places and the fixtures only reach one". Three rows now go through the cookie-jar loop, reusing the fixtures already
Mutation, not assertion. Each mutant is applied to the jar loop only,
The first row is your mutant, reproduced: green before, dead now. The second Clean suite: 129 → 132 passed, 0 failed, rc 0. |
There was a problem hiding this comment.
Build & Tests
bash test/run.sh at 66e20a4 on this armhf Pi — 132 passed, 0 failed,
exit code read from the unpiped command, up from the 129 I measured at
e163413. CI green on both legs. carp-fmt --check clean over all seven
.carp files outside out/, docs/ and examples/, from a binary built
fresh at carp-fmt 5e1a550; angler clean over the same seven, from a binary
built on angler#30's branch so the new byte-offset-as-char-index rule is
included. carp -x gendocs.carp leaves the tree clean. The branch sits on
b876596, origin/main's head, with nothing to rebase over. No CHANGELOG in
this repo.
The "source untouched" claim is literal: git diff e163413..66e20a4 -- http-client.carp is empty, and the whole commit is 63 insertions / 25
deletions in test/http-client.carp. Nothing reviewed in either earlier round
re-opens.
Prior feedback
The round-2 finding is closed, and I measured it rather than reading the new
fixtures. Four mutants — the three the follow-up names, each applied at
request-stream-with-jar- only and leaving request-stream- alone, plus one
of my own on the other loop:
| mutant | suite |
|---|---|
jar loop: redirect-verb reverted to the pre-PR expression |
131 / 1 — 301 redirect keeps HEAD a HEAD on the cookie-jar path |
jar loop: remove-content-headers dropped from the strip |
131 / 1 — the same drop holds on the cookie-jar path |
jar loop: the strip made unconditional ((/= &new-verb &cur-verb) -> true) |
131 / 1 — the same keep holds on the cookie-jar path |
non-jar loop: redirect-verb reverted |
129 / 3 — all three original HEAD tests |
The first of those was green at 129 / 0 last round; it now fails. Each of the
three new rows kills a different jar-loop mutant — no two of them are redundant
— and the fourth row confirms the non-jar loop is still pinned by the round-1
tests, so the two copies cannot drift silently in either direction any more.
The chain does reach the second loop: Client.head-with-jar
(http-client.carp:835) -> request-with-jar (:808) ->
request-stream-with-jar (:795) -> request-stream-with-jar- (:712).
Findings
None.
Also checked, nothing found
- The refactor is behaviour-preserving on the existing rows.
head-status-and-bodyis now(status-and-body (Client.head url)), which is
the old body verbatim with theClient.headcall lifted out, and
surviving-headersis the old body withnames/ the URL / theClient.post
call lifted intoprobe-names,probe-urlandprobe-map. The 303 and 307
expectation strings are unchanged. probe-mapis a function, not adef, so each test builds a fresh header
map and no state crosses between rows; each jar row builds its own
CookieJar.create, and/redirect-tosets no cookies, so the jar rows cannot
contaminate each other or the existing/chunked-foldedjar test.- The 307 jar row is not a weaker copy of the non-jar one. It expects all
eight names includingContent-Length, and the unconditional-strip mutant
above is caught by it specifically. - The jar loop still has one uncovered branch, and it is not yours.
Disabling the cross-originstrip-sensitive-headersin
request-stream-with-jar-alone leaves the suite at 132 / 0. That code
predates this PR and this PR does not touch it — the cross-origin fixture on
8792 only exercises the non-jar loop — so I mention it as the next thing of
this shape rather than as something to fix here. redirect-verbreads the way the RFC does. 307/308 keep the verb, GET and
HEAD keep theirs, everything else becomes GET; the deliberate scoping note
about not going as far as curl (which rewrites only POST on 301/302) still
matches the code athttp-client.carp:322-326.
Verdict: merge
The coverage gap I named is closed by measurement, not by assertion: the mutant
that was green at 129 / 0 last round now takes the suite down, and so do two
more that only the new rows catch. The source is byte-identical to the commit
reviewed in round 2, the suite is 129 -> 132, and CI is green on both legs.
Client.redirect-verbrewrote every method to GET on a 301, 302 or 303:So a
Client.headthrough a redirect stopped being a HEAD. The client issued aGET at the target and
drain-streampulled down the whole body — the transferthe caller explicitly asked to avoid, over a connection the client opens fresh
each hop.
Client.request-streamwith a HEAD verb had the same problem.Measured against the repo's mock server before the change,
Client.headat a301 to
/getcame back200 [ok]; it now comes back200 [].What the RFC says
I read §15.4 of RFC 9110 rather than going on the summary:
"For historical reasons, a user agent MAY change the request method from
POST to GET for the subsequent request."
that URI (a GET or HEAD request if using HTTP)" — and further down,
"Except for responses to a HEAD request, the representation of a 303 response
ought to contain a short hypertext note…", which only makes sense if a HEAD
can reach a 303 as a HEAD.
curl -I -Lfollows a redirect as a HEAD throughout; curl's redirect codeexempts HEAD on 303 and only rewrites POST on 301/302.
GET and HEAD now keep their method on a 301/302/303; every other method still
becomes a GET. 307/308 are untouched.
Scoping note: curl is stricter still — on a 301/302 it only rewrites POST,
leaving PUT/PATCH/DELETE alone. I did not go that far. That is a separate
behaviour change with its own blast radius, and this PR is about the method that
is provably wrong today. Happy to follow up if you want the full curl semantics.
Second fix: headers describing a body that is gone
When the method changes, the loop drops the body and calls
remove-content-length— but nothing removedContent-Type, so the follow-upGET went out advertising e.g.
Content-Type: application/jsonwith no contentat all. That is the same bug as the Content-Length one, and §15.4's
redirect-handling checklist spells out the full set:
remove-content-lengthbecomesremove-content-headersand strips exactly thatlist. The guard is unchanged — it still only fires when the verb actually
changed, which under the new
redirect-verbis precisely "the method has beenchanged to GET".
On
Transfer-Encoding, which you asked me to check: I left it out. It is notin §15.4's list — it is framing, not content metadata — and more to the point,
build-and-sendwrites the caller's body verbatim, so a caller-suppliedTransfer-Encoding: chunkedproduces an unframed, already-broken request on thefirst hop. Stripping it at the redirect would paper over a request the client
cannot construct correctly in the first place. Say the word if you'd rather have
it in the list anyway.
Tests
Four new cases in
test/http-client.carp, all four failing before the change andpassing after:
301 redirect keeps HEAD a HEAD200 [ok]200 []302 redirect keeps HEAD a HEAD200 [ok]200 []303 redirect keeps HEAD a HEAD200 [ok]200 []a redirect that changes the method drops the headers describing the bodyContent-header reached/headersThe mock server needed no changes —
do_HEADalready routes through_route,and
_sendalready suppresses the body for a HEAD, so/redirect-to?url=/get&status_code=…is HEAD-capable as it stands.The two tests that pin what must not change —
303 redirect changes POST to GETand307 redirect preserves POST method— still pass.bash test/run.sh: 128 passed, 0 failed, exit 0.anglerandcarp-fmt --checkclean over every
.carpoutsideout/,docs/andexamples/. Docsregenerated (
docs/Client.htmlonly). README's redirect paragraph updated.No CHANGELOG entry: this repo doesn't keep one, and I didn't want to start one
uninvited.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.