Skip to content

Commit f2f3c84

Browse files
authored
feat: Add Legacy Header Support (#8)
2 parents 3544d53 + 03ebc3a commit f2f3c84

File tree

7 files changed

+51
-30
lines changed

7 files changed

+51
-30
lines changed

helpers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ func make504Response(req *http.Request) (*http.Response, error) {
1414
buf.WriteString("Cache-Control: no-cache\r\n")
1515
buf.WriteString("Content-Length: 0\r\n")
1616
buf.WriteString(
17-
internal.CacheStatusHeader + ": " + internal.CacheStatusBypass.String() + "\r\n",
17+
internal.CacheStatusHeader + ": " + internal.CacheStatusBypass.Value + "\r\n",
1818
)
1919
buf.WriteString("Connection: close\r\n")
2020
buf.WriteString("\r\n")

internal/header.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package internal
2+
3+
import (
4+
"net/http"
5+
)
6+
7+
const (
8+
CacheStatusHeader = "X-Httpcache-Status"
9+
CacheStatusHeaderLegacy = "X-Cache-Status" // Deprecated: use [CacheStatusHeader] instead
10+
)
11+
12+
type CacheStatus struct {
13+
Value string
14+
// Value for compatibility with github.com/gregjones/httpcache:
15+
// "1" means served from cache (less specific "HIT")
16+
// "" means not served from cache (less specific "MISS")
17+
//
18+
// Deprecated: only used for compatibility with unmaintained (still widely
19+
// used) github.com/gregjones/httpcache; use Value instead.
20+
Legacy string
21+
}
22+
23+
func (s CacheStatus) ApplyTo(header http.Header) {
24+
header.Set(CacheStatusHeader, s.Value)
25+
if s.Legacy != "" {
26+
header.Set(CacheStatusHeaderLegacy, s.Legacy)
27+
}
28+
}
29+
30+
var (
31+
CacheStatusHit = CacheStatus{"HIT", "1"} // served from cache
32+
CacheStatusMiss = CacheStatus{"MISS", ""} // served from origin
33+
CacheStatusStale = CacheStatus{"STALE", "1"} // served from cache but stale
34+
CacheStatusRevalidated = CacheStatus{"REVALIDATED", "1"} // revalidated with origin server
35+
CacheStatusBypass = CacheStatus{"BYPASS", ""} // cache bypassed
36+
)

internal/normalization.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ func normalizeOrderInsensitiveWithQValues(value string) string {
151151
}
152152
parts := slices.Collect(TrimmedCSVSeq(value))
153153
qualityParts := make([]qualityValue, 0, len(parts))
154+
outer:
154155
for i := range parts {
155156
part := parts[i]
156157
main, allParamsRaw, found := strings.Cut(part, ";")
@@ -164,7 +165,7 @@ func normalizeOrderInsensitiveWithQValues(value string) string {
164165
case len(param) > 2 && strings.EqualFold(param[:2], "q="):
165166
qRaw := param[2:]
166167
if qRaw == "0" || qRaw == "0.0" {
167-
goto skipQualityPart // skip this part, as it has q=0
168+
continue outer // skip this part, as it has q=0
168169
}
169170
if qVal, err := strconv.ParseFloat(qRaw, 64); err == nil {
170171
q = unique.Make(
@@ -182,10 +183,6 @@ func normalizeOrderInsensitiveWithQValues(value string) string {
182183
q: q,
183184
params: params,
184185
})
185-
continue
186-
187-
skipQualityPart:
188-
continue
189186
}
190187

191188
// Sort by quality value, then by number of wildcards in main,

internal/validationresponsehandler.go

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,6 @@ import (
55
"time"
66
)
77

8-
const CacheStatusHeader = "X-Httpcache-Status"
9-
10-
type CacheStatus string
11-
12-
// ApplyTo sets the cache status header on the provided HTTP header.
13-
func (s CacheStatus) ApplyTo(header http.Header) { header.Set(CacheStatusHeader, s.String()) }
14-
func (s CacheStatus) String() string { return string(s) }
15-
16-
const (
17-
CacheStatusHit CacheStatus = "HIT" // Response was served from cache
18-
CacheStatusMiss CacheStatus = "MISS" // Response was not found in cache, and was served from origin
19-
CacheStatusStale CacheStatus = "STALE" // Response was served from cache but is stale
20-
CacheStatusRevalidated CacheStatus = "REVALIDATED" // Response was revalidated with the origin server
21-
CacheStatusBypass CacheStatus = "BYPASS" // Response was not served from cache due to cache bypass
22-
)
23-
248
type ValidationResponseHandler interface {
259
HandleValidationResponse(
2610
ctx RevalidationContext,

roundtripper.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -283,12 +283,8 @@ func (r *transport) handleCacheHit(
283283
return r.serveFromCache(stored, freshness, isRespNoCacheQualified, respNoCacheFieldsSeq)
284284
}
285285

286-
if freshness.IsStale && ccResp.MustRevalidate() {
287-
goto revalidate
288-
}
289-
290-
// Unqualified no-cache: must revalidate before serving from cache
291-
if hasRespNoCache && !isRespNoCacheQualified {
286+
if (freshness.IsStale && ccResp.MustRevalidate()) ||
287+
(hasRespNoCache && !isRespNoCacheQualified) { // Unqualified no-cache: must revalidate before serving from cache
292288
goto revalidate
293289
}
294290

@@ -337,6 +333,8 @@ func (r *transport) serveFromCache(
337333
return stored.Data, nil
338334
}
339335

336+
// handleStaleWhileRevalidate serves a stale cached response immediately and triggers
337+
// background revalidation in a separate goroutine (RFC 5861, §3).
340338
func (r *transport) handleStaleWhileRevalidate(
341339
req *http.Request,
342340
stored *internal.Response,
@@ -346,6 +344,12 @@ func (r *transport) handleStaleWhileRevalidate(
346344
) (*http.Response, error) {
347345
req2 := req.Clone(req.Context())
348346
req2 = withConditionalHeaders(req2, stored.Data.Header)
347+
// Background revalidation is "best effort"; it is not guaranteed to complete
348+
// if the program exits before the goroutine finishes. This design choice was
349+
// made to keep the API simple and avoid requiring explicit shutdown coordination.
350+
//
351+
// Open a discussion at github.com/bartventer/httpcache/issues if your use case requires
352+
// guaranteed completion.
349353
go r.performBackgroundRevalidation(req2, stored, urlKey, freshness, ccReq)
350354
internal.CacheStatusStale.ApplyTo(stored.Data.Header)
351355
return stored.Data, nil

roundtripper_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func mockTransport(fields func(rt *transport)) *transport {
6161
func assertCacheStatus(t *testing.T, resp *http.Response, expectedStatus internal.CacheStatus) {
6262
t.Helper()
6363
status := resp.Header.Get(internal.CacheStatusHeader)
64-
if status != expectedStatus.String() {
64+
if status != expectedStatus.Value {
6565
t.Errorf("expected cache status %s, got %s", expectedStatus, status)
6666
}
6767
}

store/memcache/memcache.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Package memcache provides an in-memory implementation of store.Cache.
1+
// Package memcache implements an in-memory cache backend.
22
//
33
// It is suitable for testing or ephemeral caching needs, but does not persist data
44
// across process restarts.

0 commit comments

Comments
 (0)