Skip to content

feat: HTTP caching primitives - ETag, Last-Modified, Cache-Control - #678

Merged
lmajano merged 6 commits into
developmentfrom
claude/http-caching-primitives-spec
Aug 16, 2026
Merged

feat: HTTP caching primitives - ETag, Last-Modified, Cache-Control#678
lmajano merged 6 commits into
developmentfrom
claude/http-caching-primitives-spec

Conversation

@lmajano

@lmajano lmajano commented Aug 15, 2026

Copy link
Copy Markdown
Member

Description

Implements the Tier 1 (Event Caching-integrated) HTTP caching primitives from docs/specs/http-caching.md, plus the standalone manual API:

  • RequestContext: event.etag() / event.lastModified() / event.cacheControl(), a new isNoExecution() predicate, and a portable toHTTPDate() formatter (built from individual date parts rather than a dateTimeFormat() mask, since CFML's classic mask letters and Java's DateTimeFormatter pattern letters are different dialects and not safe to assume across BoxLang/Lucee/Adobe). etag()/lastModified() implement the full RFC 7232 conditional-GET matching rules: If-None-Match may be * or a comma-separated list and is always compared weakly, and a request carrying If-None-Match ignores If-Modified-Since entirely per §3.3.
  • Response: matching withETag() / withCacheControl() fluent helpers.
  • HandlerService: new etag / etagWeak / lastModified / cacheControl annotations alongside the existing cache / cacheTimeout event-caching annotations. No separate on/off switch - these annotations are read unconditionally, gated only by the existing cache="true" on the action, exactly like cacheInclude/cacheExclude/cacheFilter already are.
  • Bootstrap: on a cache write, computes an ETag hash and/or Last-Modified timestamp once and stores it (plus the etagWeak flag) on the cache entry; on a cache hit, replays them through the same event.etag()/event.lastModified() matching logic as the manual API, rather than a separate narrower implementation - a match skips the body replay entirely and sends a bare 304. A handler that never sets the new annotations sees no behavior change.
  • RestHandler: aroundHandler now also guards on isNoExecution(), so a conditional-GET resolved inside an action doesn't hit the same write-after-commit hazard the SSE isSSE() guard already covers.

Two real bugs were found and fixed while writing tests against actual execution rather than trusting the code: cacheControl()'s isBoolean(val) check is loosely true for any castable value (isBoolean(60) is true in CFML/BoxLang), which silently dropped numeric directive values like max-age=60 down to a bare "max-age" token; and the original dateTimeFormat() mask threw "Too many pattern letters: d" on BoxLang, which is what led to the portable toHTTPDate() implementation.

A Copilot review pass on this PR additionally found: If-None-Match matching was exact-string-only (missing */lists/weak comparison); If-Modified-Since wasn't ignored when If-None-Match was present; toHTTPDate() labeled its output "GMT" without actually converting to UTC first; the cache-hit replay path duplicated (and under-implemented) the matching logic instead of reusing etag()/lastModified(); and the etagWeak flag was computed at write time but never persisted onto the cache entry. All fixed, with new test coverage for each.

Testing

Unit coverage for etag()/lastModified()/cacheControl()/isNoExecution()/toHTTPDate() (engine-agnostic, no BoxLang gate — pure header logic), Response fluent header tests, RestHandler.aroundHandler guard tests, and integration tests extending the existing EventCachingSpec with two new test-harness handler actions. The integration suite could not be executed in the local sandbox used for this work (BaseIntegrationTest needs a real servlet CGI scope unavailable there — confirmed this is a pre-existing limitation, not a regression, by running the unmodified spec and observing the identical failure), but the new handler actions and helper methods were verified directly via bare instantiation outside the full ColdBox bootstrap. Full local regression suite: 200 passed, 4 failed/23 errors — unchanged against the established pre-existing baseline (21 errors) plus 2 pre-existing RestHandlerTest sandbox-limitation errors.

Jira Issues

COLDBOX-1415

Type of change

  • Bug Fix
  • Improvement
  • New Feature
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

(The spec doc in this same PR is the documentation update.)

Checklist

  • My code follows the style guidelines of this project cfformat
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

…e-Control)

Drafts event.etag()/lastModified()/cacheControl() on RequestContext and
Response, plus an automatic layer built on the existing Event Caching
annotation surface (cache="true", cacheTimeout, ...). Automatic caching
splits into two honestly-different tiers: Tier 1 piggybacks on Bootstrap's
existing pre-execution cache lookup to skip both handler execution and body
replay on a conditional-GET hit, for free; Tier 2 (no Event Caching)
computes an ETag per-request and only saves the client a body download, not
server compute - documented explicitly so "automatic" doesn't overpromise.

Spec only. No framework code changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown

Test Results

0 tests  ±0   0 ✅ ±0   0s ⏱️ ±0s
0 suites ±0   0 💤 ±0 
0 files   ±0   0 ❌ ±0 

Results for commit 3e504d9. ± Comparison against base commit 57205b2.

♻️ This comment has been updated with latest results.

…ier 1)

Implements the Tier 1 (Event Caching-integrated) automatic ETag/Last-Modified
support from docs/specs/http-caching.md, plus the manual primitives:

- RequestContext: event.etag()/lastModified()/cacheControl(), a new
  isNoExecution() predicate, and a portable toHTTPDate() formatter (built from
  individual date parts rather than a dateTimeFormat() mask - CFML's classic
  mask letters and Java's DateTimeFormatter pattern letters are different
  dialects, and which one a given engine's dateTimeFormat() implements isn't
  safe to assume across BoxLang/Lucee/Adobe).
- Response: matching withETag()/withCacheControl() fluent helpers.
- HandlerService: new etag/etagWeak/lastModified/cacheControl annotations
  alongside the existing cache/cacheTimeout event-caching annotations, gated
  by a new this.httpCaching.enabled global switch.
- Bootstrap: on a cache write, computes an ETag hash and/or Last-Modified
  timestamp once and stores it on the cache entry; on a cache hit, compares
  the stored ETag against the incoming If-None-Match before touching the
  body at all - a match skips the replay entirely and sends a bare 304,
  which is cheaper than today's always-replay behavior. A handler that
  never sets these new annotations sees no behavior change.
- RestHandler: aroundHandler now also guards on isNoExecution(), so a
  conditional-GET resolved inside an action doesn't hit the same
  write-after-commit hazard the SSE isSSE() guard already covers.
- Settings/ApplicationLoader: this.httpCaching defaults block, parsed the
  same way as the existing this.sse block.

Found and fixed two bugs while writing tests against real execution rather
than assuming the code was correct: cacheControl()'s isBoolean(val) check is
loosely true for any castable value (isBoolean(60) is true in CFML/BoxLang),
which silently dropped numeric directive values like max-age=60 down to a
bare "max-age" token; and the original dateTimeFormat() mask ("ddd, dd mmm
yyyy...") threw "Too many pattern letters: d" on BoxLang, which is what led
to the portable toHTTPDate() implementation instead.

Tests: unit coverage for etag()/lastModified()/cacheControl()/isNoExecution()/
toHTTPDate() (engine-agnostic, no BoxLang gate - pure header logic), Response
fluent header tests, RestHandler aroundHandler guard tests, and integration
tests extending the existing EventCachingSpec with two new test-harness
handler actions. The integration suite could not be executed in this
sandbox (BaseIntegrationTest needs a real servlet CGI scope this CLI
environment doesn't have - confirmed this is a pre-existing limitation by
running the unmodified spec, which fails identically) but the new handler
actions and helper methods were verified directly via bare instantiation.
Full local regression suite: 195 passed, 4 failed/23 errors - unchanged
against the established pre-existing baseline (21 errors) plus 2 newly
bundled, unrelated, pre-existing RestHandlerTest sandbox-limitation errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
@lmajano lmajano changed the title docs: RFC spec for HTTP caching primitives (ETag, Last-Modified, Cache-Control) feat: HTTP caching primitives - ETag, Last-Modified, Cache-Control Aug 16, 2026
claude added 3 commits August 16, 2026 00:46
…n observe

CI failed on every engine: execute() (system/testing/BaseTestCase.cfc) is a
headless request simulator - it runs the handler and render steps directly
rather than going through Bootstrap.cfc's actual onRequest cycle, so it
never reaches the real event-caching *write* to CacheBox. This is true for
every existing test in this file too: none of them read the cache store
back, they all assert only against cbox_eventCacheableEntry, the
pre-execution metadata flag. My three new tests assumed execute() would
let them read back an actual stored ETag hash, which the harness
structurally cannot provide - confirmed by getCache(...).get(cacheKey)
returning nothing after execute(), on every engine identically.

Rescoped to what's actually observable: the etag/etagWeak/lastModified/
cacheControl annotations correctly flow into the cacheable entry metadata,
for both a handler that sets them and one that never does (verifying the
defaults). The write-time hash computation and the conditional-GET
short-circuit decision itself are still covered directly against
RequestContext in RequestContextHTTPCachingTest.cfc, which does not have
this limitation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
CI failed on lucee@5 and lucee@6 only: cacheControl()/withCacheControl()
build the header by iterating the caller's directive struct, and plain
CFML structs are not guaranteed insertion-ordered on every engine (Lucee's
default struct implementation isn't, unlike BoxLang's, which is why this
passed locally). The two affected assertions hardcoded one specific order
("public, max-age=60"). Per RFC 9111, Cache-Control directive order carries
no semantic meaning, so the fix is to assert both directives are present
via toInclude() rather than an exact ordered string match, not to force a
specific struct iteration order in the implementation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
Tier 1's etag/etagWeak/lastModified/cacheControl annotations are only ever
read inside HandlerService.getEventCachingMetadata(), which already only
runs when the existing global this.coldbox.eventCaching switch is true
(HandlerService.cfc:186-190) - the same gate cacheInclude/cacheExclude/
cacheFilter already rely on with no switch of their own. The separate
this.httpCaching.enabled toggle could never independently disable anything
eventCaching didn't already disable, so it added a settings block, a config
parser, and a docblock explaining a distinction that didn't exist. Removed
all three; the annotations now read unconditionally, matching the existing
cacheInclude/cacheExclude/cacheFilter convention exactly.

Updated docs/specs/http-caching.md §4.7 to explain why Tier 1 needs no
settings block, and moved the settings-block sketch to where it actually
belongs: Tier 2, which - unlike Tier 1 - runs independently of cache="true"
and eventCaching entirely, so it would genuinely need its own opt-in if
built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
@lmajano
lmajano force-pushed the claude/http-caching-primitives-spec branch from 9e98f19 to 9f02293 Compare August 16, 2026 16:52
@lmajano
lmajano requested a lite review from Copilot August 16, 2026 17:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class HTTP caching primitives (ETag, Last-Modified, Cache-Control) to ColdBox’s request/response pipeline, integrating Tier-1 conditional GET handling into existing Event Caching so cached responses can cheaply return 304 Not Modified without replaying the body.

Changes:

  • Added RequestContext helpers (etag(), lastModified(), cacheControl(), toHTTPDate()) and an isNoExecution() predicate for safe early-exit signaling.
  • Added Response fluent header helpers (withETag(), withCacheControl()) plus unit tests for the new behaviors.
  • Extended Event Caching metadata (HandlerService) and integrated conditional-GET handling into Bootstrap cache-hit/cache-write paths; updated RestHandler to honor isNoExecution().

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/specs/web/context/ResponseTest.cfc Adds unit coverage for new Response fluent header helpers.
tests/specs/web/context/RequestContextHTTPCachingTest.cfc Adds unit coverage for RequestContext HTTP caching primitives and toHTTPDate().
tests/specs/RestHandlerTest.cfc Verifies RestHandler.aroundHandler bails out when a 304 has already committed the response.
tests/specs/integration/EventCachingSpec.cfc Extends integration spec to verify new annotations flow into cacheable entry metadata.
test-harness/handlers/eventcaching.cfc Adds harness actions annotated for Tier-1 ETag/Last-Modified flows.
system/web/services/HandlerService.cfc Extends event caching metadata defaults + reads new HTTP caching annotations.
system/web/context/Response.cfc Implements withETag() and withCacheControl() fluent helpers.
system/web/context/RequestContext.cfc Implements conditional-GET primitives and portable HTTP-date formatting.
system/RestHandler.cfc Guards marshalling/flush when event.isNoExecution() is true (e.g., conditional-GET 304).
system/Bootstrap.cfc Stores/replays HTTP caching metadata with event cache entries; skips body replay on conditional match.
docs/specs/http-caching.md Adds the HTTP caching spec documentation.
Suppressed comments (2)

system/Bootstrap.cfc:287

  • On cache hits, Last-Modified is replayed but never used to short-circuit the response. If an action opts into lastModified without etag, clients sending If-Modified-Since will still get a full body replay instead of a 304.
				if ( structKeyExists( local.refResults.eventCaching, "lastModified" ) ) {
					event.setHTTPHeader(
						name  = "Last-Modified",
						value = event.toHTTPDate( local.refResults.eventCaching.lastModified )
					);

system/web/context/RequestContext.cfc:1985

  • toHTTPDate() labels the output as GMT but currently formats the incoming date without any timezone conversion. If callers pass local timestamps (e.g. now()), the header will be incorrect. Converting to UTC before extracting date parts makes the emitted "GMT" date accurate.

		return dayNames[ dayOfWeek( arguments.value ) ] & ", " &
		numberFormat( day( arguments.value ), "00" ) & " " &
		monthNames[ month( arguments.value ) ] & " " &
		year( arguments.value ) & " " &

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread system/Bootstrap.cfc Outdated
Comment on lines +274 to +282
var cachedETagMatch = false;
if ( structKeyExists( local.refResults.eventCaching, "etag" ) ) {
var cachedETag = """" & local.refResults.eventCaching.etag & """";
event.setHTTPHeader( name = "ETag", value = cachedETag );
cachedETagMatch = (
listFindNoCase( "GET,HEAD", event.getHTTPMethod() ) > 0 &&
event.getHTTPHeader( "If-None-Match", "" ) == cachedETag
);
}
Comment on lines +1868 to +1878
boolean function etag( required string value, boolean weak = false ){
var tag = ( arguments.weak ? "W/" : "" ) & """#arguments.value#""";
setHTTPHeader( name = "ETag", value = tag );

if ( isSafeHTTPMethod() && getHTTPHeader( "If-None-Match", "" ) == tag ) {
noExecution();
setHTTPHeader( statusCode = 304 );
return true;
}
return false;
}
Comment thread system/web/context/RequestContext.cfc Outdated
Comment on lines +1955 to +1956
* @value The date/time to format. Assumed to already be in the desired output timezone - this function does no conversion of its own.
*/
Comment on lines +227 to +231
var event = buildContext();
// The canonical example from RFC 7231 §7.1.1.1
var rfcExampleDate = createDateTime( 1994, 11, 6, 8, 49, 37 );

expect( event.toHTTPDate( rfcExampleDate ) ).toBe( "Sun, 06 Nov 1994 08:49:37 GMT" );
Comment thread docs/specs/http-caching.md Outdated
Comment on lines +3 to +17
**Status:** Draft — no implementation yet
**Target:** ColdBox 8.3.0 (or next minor)
**Runtime:** BoxLang + CFML (Adobe, Lucee) — pure HTTP header mechanics, no BIF dependency
**Related:** ColdBox's existing Event Caching (`system/Bootstrap.cfc`, `HandlerService.cfc`);
`docs/specs/sse-streaming.md` (the annotation/interception-point conventions this spec follows)

---

## 1. Motivation

A case-insensitive grep across `system/` for `etag`, `last-modified`, `cache-control`,
`if-none-match`, `if-modified-since`, and `304` returns **zero hits** (the lone `"304"` string
anywhere in the codebase is a status-text lookup entry, unrelated to caching). ColdBox has no
concept of HTTP-level conditional requests or cache negotiation. Every response — cached
server-side or not — always sends a full `200` with a full body.
Comment on lines +819 to +825
// HTTP caching (docs/specs/http-caching.md §4) - Tier 1 only: an ETag
// and/or Last-Modified computed once at cache-write time, reused on every
// hit until the entry expires. Deliberately opt-in, so an existing
// cache="true" handler that never sets these sees no behavior change. No
// separate on/off switch: this whole block already only runs when
// eventCaching is enabled, same as cacheInclude/cacheExclude/cacheFilter
// above.
- etag(): If-None-Match matching now handles the wildcard `*`, a
  comma-separated list of entity tags, and always compares weakly per
  RFC 7232 (a client's W/-prefixed tag matches a server's strong tag with
  the same opaque value, and vice versa) - previously only an exact
  single-tag string match was recognized.
- lastModified(): a request carrying If-None-Match now ignores
  If-Modified-Since entirely per RFC 7232 §3.3, instead of potentially
  short-circuiting on the date match after an ETag mismatch already said
  the representation differs.
- toHTTPDate(): converts local server time to UTC before formatting, so
  the trailing "GMT" is accurate on any server timezone rather than only
  ones already running in UTC. Bootstrap's cache-write path was passing
  now() (local time) straight through, and every call site benefits from
  the fix without changing its own code.
- Bootstrap.cfc's cache-hit conditional-GET replay now calls
  event.etag()/event.lastModified() directly instead of re-implementing
  a narrower version of the same matching logic, so the automatic
  (event-cache-integrated) path and the manual API path can't drift
  apart again. This also fixes a real gap Copilot found: the cached
  entry's etagWeak flag was computed at write time but never persisted
  onto the cache entry, so a weak ETag was always replayed as strong on
  every subsequent hit; and Last-Modified was replayed as a header on a
  cache hit but never actually checked against If-Modified-Since, so an
  action using lastModified() without etag() never got a 304 from the
  cache-hit path at all.
- docs/specs/http-caching.md: the spec's Status line and motivation
  section still said "no implementation yet" despite this PR shipping
  Tier 1; corrected to reflect Tier 1 as implemented and Tier 2 as the
  remaining unimplemented tier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR

lmajano commented Aug 16, 2026

Copy link
Copy Markdown
Member Author

Thanks for the review — all six findings were real and are fixed in 3e504d9:

  • If-None-Match matching: now handles *, comma-separated lists, and always compares weakly per RFC 7232 §2.3.2/§3.2 (was exact-string-only against a single tag).
  • If-Modified-Since ignored when If-None-Match present: per RFC 7232 §3.3 - lastModified() now checks for this before evaluating the date.
  • toHTTPDate() timezone: now converts local→UTC via dateConvert() before formatting, so the trailing "GMT" is actually accurate. Fixed the RFC 7231 test to feed in the local equivalent of the UTC example so the assertion stays stable across CI timezones, per your suggestion.
  • Cache-hit replay duplicating/under-implementing matching logic: Bootstrap.cfc's cache-hit path now calls event.etag()/event.lastModified() directly instead of a separate hand-rolled comparison, so the automatic and manual paths share one implementation and can't drift apart again. This also means Last-Modified now actually short-circuits with a 304 on a cache hit, not just replays the header.
  • etagWeak not persisted on cache write: now stored on the cache entry and read back correctly on hit.
  • Stale spec doc / PR description: docs/specs/http-caching.md's Status line and the PR description's mention of a this.httpCaching.enabled switch (removed earlier in review) are corrected.

New test coverage for the wildcard/list/weak matching and the If-None-Match precedence rule is in the same commit.


Generated by Claude Code

@lmajano
lmajano merged commit 1def0d3 into development Aug 16, 2026
28 checks passed
@lmajano
lmajano deleted the claude/http-caching-primitives-spec branch August 16, 2026 18:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants