From 37cf0f869f1e30c9dfb8dcb4f4b9da314feb06e6 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:16:59 -0700 Subject: [PATCH 1/3] feat: add MaxAge and TTLRemaining to CandidateResponse (#250) --- fsthttp/cache.go | 57 ++++++++++++++++++++++++++--- integration_tests/httpcache/main.go | 10 +++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/fsthttp/cache.go b/fsthttp/cache.go index d6adf1a..7e5be46 100644 --- a/fsthttp/cache.go +++ b/fsthttp/cache.go @@ -378,11 +378,13 @@ func (candidateResponse *CandidateResponse) Age() (uint32, error) { return opts.age, nil } -// TTL returns the Time to Live (TTL) in seconds in the cache for this response. +// MaxAge returns the duration of "freshness", in seconds, for the cached +// response after it is inserted into the cache. // -// The TTL determines the duration of "freshness" for the cached response -// after it is inserted into the cache. -func (candidateResponse *CandidateResponse) TTL() (uint32, error) { +// This is the full freshness lifetime of the object: it does not subtract the +// time the object has already spent in cache (its age). Use TTLRemaining to +// find out how much freshness is left right now. +func (candidateResponse *CandidateResponse) MaxAge() (uint32, error) { if candidateResponse.useTTL { return candidateResponse.overrideTTL, nil } @@ -391,7 +393,52 @@ func (candidateResponse *CandidateResponse) TTL() (uint32, error) { return 0, err } - return opts.maxAge - opts.age, nil + return opts.maxAge, nil +} + +// TTLRemaining returns the remaining "freshness", in seconds, of the cached +// response: how much longer the object stays fresh from now. +// +// This is the object's full freshness lifetime (MaxAge) minus the time it has +// already spent in cache (its age). When the object is already stale (its age +// meets or exceeds its full lifetime) the remaining freshness is reported as 0 +// rather than wrapping around. +func (candidateResponse *CandidateResponse) TTLRemaining() (uint32, error) { + var maxAge, age uint32 + if candidateResponse.useTTL { + // A set TTL is the full freshness lifetime; the remaining freshness + // still accounts for the time already spent in cache. + maxAge = candidateResponse.overrideTTL + opts, err := candidateResponse.getSuggestedCacheWriteOptions() + if err != nil { + return 0, err + } + age = opts.age + } else { + opts, err := candidateResponse.getSuggestedCacheWriteOptions() + if err != nil { + return 0, err + } + maxAge, age = opts.maxAge, opts.age + } + + if age >= maxAge { + return 0, nil + } + return maxAge - age, nil +} + +// TTL returns the remaining Time to Live (TTL) in seconds in the cache for this +// response. +// +// The TTL determines the duration of "freshness" for the cached response +// after it is inserted into the cache. +// +// Deprecated: use TTLRemaining or MaxAge. The name TTL does not make clear +// whether it reports the full freshness lifetime of the object or the +// remaining freshness; it returns the remaining freshness (TTLRemaining). +func (candidateResponse *CandidateResponse) TTL() (uint32, error) { + return candidateResponse.TTLRemaining() } // SetTTL sets the Time to Live (TTL) in seconds in the cache for this response. diff --git a/integration_tests/httpcache/main.go b/integration_tests/httpcache/main.go index bfc126a..ccd3416 100644 --- a/integration_tests/httpcache/main.go +++ b/integration_tests/httpcache/main.go @@ -545,6 +545,16 @@ func testAfterSendCandidateResponsePropertiesUncached(ctx context.Context) error return fmt.Errorf("candidate has ttl=%v or err=%v, want 3600, nil", ttl, err) } + if maxAge, err := r.MaxAge(); maxAge != 3600 || err != nil { + return fmt.Errorf("candidate has maxAge=%v or err=%v, want 3600, nil", maxAge, err) + } + + // age is 0 here, so the remaining TTL equals the full freshness + // lifetime and matches the deprecated TTL() value. + if remaining, err := r.TTLRemaining(); remaining != 3600 || err != nil { + return fmt.Errorf("candidate has ttlRemaining=%v or err=%v, want 3600, nil", remaining, err) + } + if got, err := r.Vary(); err != nil { return fmt.Errorf("candidate.vary has err=%v, want nil", err) } else if want := ""; got != want { From 7751e7ef4016216ddb3c76c9723824ba13bdf6b2 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 9 Jun 2026 05:43:01 -0700 Subject: [PATCH 2/3] chore: fix lint Drop the deprecated CandidateResponse.TTL() call in the uncached candidate test; TTLRemaining() already covers it (age is 0, so remaining == full freshness lifetime). Fixes staticcheck SA1019. Co-Authored-By: Claude Opus 4.8 (1M context) --- integration_tests/httpcache/main.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/integration_tests/httpcache/main.go b/integration_tests/httpcache/main.go index ccd3416..1f26142 100644 --- a/integration_tests/httpcache/main.go +++ b/integration_tests/httpcache/main.go @@ -541,16 +541,12 @@ func testAfterSendCandidateResponsePropertiesUncached(ctx context.Context) error return fmt.Errorf("candidate has age=%v or err=%v, want 0, nil", age, err) } - if ttl, err := r.TTL(); ttl != 3600 || err != nil { - return fmt.Errorf("candidate has ttl=%v or err=%v, want 3600, nil", ttl, err) - } - if maxAge, err := r.MaxAge(); maxAge != 3600 || err != nil { return fmt.Errorf("candidate has maxAge=%v or err=%v, want 3600, nil", maxAge, err) } // age is 0 here, so the remaining TTL equals the full freshness - // lifetime and matches the deprecated TTL() value. + // lifetime. if remaining, err := r.TTLRemaining(); remaining != 3600 || err != nil { return fmt.Errorf("candidate has ttlRemaining=%v or err=%v, want 3600, nil", remaining, err) } From 00b3a4c9c098bcc84dc22da1f56ca78b544ff333 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:56:07 -0700 Subject: [PATCH 3/3] refactor: simplify TTLRemaining control flow Fetch the suggested cache write options once and override maxAge when a TTL is set, per review feedback. --- fsthttp/cache.go | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/fsthttp/cache.go b/fsthttp/cache.go index 7e5be46..1a7da3d 100644 --- a/fsthttp/cache.go +++ b/fsthttp/cache.go @@ -404,22 +404,15 @@ func (candidateResponse *CandidateResponse) MaxAge() (uint32, error) { // meets or exceeds its full lifetime) the remaining freshness is reported as 0 // rather than wrapping around. func (candidateResponse *CandidateResponse) TTLRemaining() (uint32, error) { - var maxAge, age uint32 + opts, err := candidateResponse.getSuggestedCacheWriteOptions() + if err != nil { + return 0, err + } + maxAge, age := opts.maxAge, opts.age if candidateResponse.useTTL { // A set TTL is the full freshness lifetime; the remaining freshness // still accounts for the time already spent in cache. maxAge = candidateResponse.overrideTTL - opts, err := candidateResponse.getSuggestedCacheWriteOptions() - if err != nil { - return 0, err - } - age = opts.age - } else { - opts, err := candidateResponse.getSuggestedCacheWriteOptions() - if err != nil { - return 0, err - } - maxAge, age = opts.maxAge, opts.age } if age >= maxAge {