Answer a cached 304 under the headers its body came with
With the HTTP cache on, Go and Rust answered a 304 Not Modified with the cached body but only the 304's own headers. The cache never stored the 200's headers. A 304 doesn't have to repeat the Link and X-Total-Count of the page it confirms, so a paginated read revalidated that way lost its next page and its count. GetAll stopped after page one, FollowPagination found no next link, and a Page reported no total. hey-cli turns the Go cache on, so hey mcp hits this first.
Kotlin, Swift and TypeScript already store the headers and merge the 304's in. This brings Go and Rust in line with them, and adds a conformance case so all five stay in line.
The rule
- A cache entry stores the headers its body came with, minus credentials (
Authorization,Proxy-Authorization,Cookie,Set-Cookie,X-CSRF-Token) and the connection's own fields, which RFC 9111 §3.1 keeps out of a cache (Connectionand the fields it lists,Keep-Alive,Proxy-Connection,TE,Transfer-Encoding,Upgrade). - A 304 is answered with the stored headers, and each field the 304 carried replaces the stored field of that name.
Content-Lengthis the exception: it describes the 304's empty body (RFC 9111 §3.2). - The caller gets everything the 304 sent (a fresh
Set-Cookieincluded). The entry keeps only what may be stored (RFC 9111 §4.3.4): the updated headers, plus the 304'sETagwhen it names a new one. So a count or cursor that HEY moved on a 304 is still there on the next bare 304. Only metadata is written; the body on disk stays as it is. - A 304 is answered from the entry the request went out conditional on, read whole before sending. Its update is written only while that entry is still the one stored, because a body stored meanwhile is newer than anything the 304 confirms.
- An entry written before headers were stored counts as a miss, not a conditional request. Revalidating it would answer without its
Link. Each such page is fetched in full once after the upgrade.
| SDK | Change |
|---|---|
Go (cache.go, client.go, generated_cache.go) | Cache.SetResponse(key, body, etag, header) and Cache.GetHeaders(key), stored as responses/<key>.headers (JSON, name → values). Set still works and stores no headers. Both 304 paths use the stored headers: hand-written singleRequest and the generated client's cachingDoer. Names are read back in canonical form, whatever case another SDK wrote them in. Additive API. |
Rust (cache.rs, client.rs) | CachedResponse gains headers: HeaderMap. ResponseCache gains revalidate, with a default that reads and re-sets the entry. FileCache and InMemoryCache override it to write only the metadata, conditional on the validator. FileCache reads and writes the same responses/<key>.headers file Go uses, since the two already share a layout. |
| Kotlin, Swift, TypeScript | No change. They already store headers and merge the 304's (CachedResponse.headersUpdatedBy / headersUpdated(by:), and TS client.ts). |
Rust: breaking. Adding a public field to CachedResponse, which callers can build literally, breaks any ResponseCache implemented outside the crate. This PR carries the breaking label, and MIGRATING.md gets a "Rust: the first release after 0.32.1" section. I didn't bump the version here. That belongs to the release PR, which needs 0.MINOR for Rust. The non-breaking alternative was default-method hooks on ResponseCache. Third-party caches would then lose headers silently, which is this same bug, so I didn't take it.
Tests: each fails without the fix
- Go:
cache_headers_test.goTestPaginationSurvivesARevalidationThatOmitsItsHeaders:GetAllover two pages, then again with every page answered by a bare 304. Both pages come back, and the third read still carriesX-Total-Countand noSet-Cookie. Without the fix the second walk stops after page one.TestGeneratedOperationsKeepPaginationHeadersThroughABareRevalidation: generatedFolders().GetPage. A bare 304 keepsTotalCountandNextPage. A 304 carrying a new count updates it, and the following bare 304 still has the update. I checked that removing the entry update fails the last step.TestCacheEntriesWithoutHeadersAreRefetched: old-format entries go out unconditional on both paths.TestARevalidationAnswersTheVersionItConfirms: on both paths, a v2 is stored while the v1 revalidation is held open. The answer is v1's body and headers, and the entry stays v2.TestARevalidationKeepsItsMetadataWithoutRewritingTheBody: a 304's new ETag is sent on the next read, itsSet-Cookiereaches the caller and not the disk, and the body file keeps its inode.TestCacheStoresHeadersWithoutCredentials: filtering, lowercase names written by Rust read back, andInvalidatedrops the file.
- Rust:
tests/cache.rsa_bare_304_keeps_the_page_it_confirms_{in_memory,on_disk}runs the same flow throughboxes().list()andnext_page, including the update that persists.the_file_cache_keeps_headers_beside_the_bodychecks filtering, names written by Go, and that an entry without a headers file is a miss.a_304_updates_the_entry_without_rewriting_its_bodyanda_revalidation_leaves_an_entry_replaced_meanwhile_aloneare the Rust counterparts of the two Go tests above. They check the new ETag,Set-Cookie, the body inode, and the conditional update on both stores.- Answering with the 304's headers alone, or storing none, fails both flow tests. Filtering the 304 before merging, or dropping
FileCache'srevalidate, fails the metadata test.
- Conformance:
cache-revalidation.jsongains "Cache-enabled paginated read keeps its pagination headers through a bare 304". It's a cachedGetCollectionwhose second read is a bare 304, withresponseMeta.nextPageandtotalCountasserted. It fails on Go without the fix ("Expected next page "next-cursor", got """). It passes on all five runners with it.- The Go runner can now read a hand-written page's pagination state in the HEY layer.
- The Rust, Kotlin and Swift runners send HEY-layer
GetCollectionthrough the generated path on the case's cached client. - The TS fixture inventory is regenerated.
Not in this PR
- Who sends a bare 304. In practice HEY often repeats the headers. geared_pagination sets
Link/X-Total-Countin anafter_actionthat runs on 304s too, andTestGeneratedOperationsReadPaginationHeadersFromTheRevalidationrelies on that. A 304 can still arrive without them, from any page that isn't geared or from anything between us and HEY. The SDK shouldn't depend on the server repeating them. Cache-Control: no-store. Kotlin and Swift honorno-store, and Go and Rust still don't. That's a separate parity gap, not part of this bug.- Hop-by-hop fields. Kotlin, Swift and TS don't strip them from what they store. Their HTTP stacks already drop most of them, so I left those three alone.