feat: HTTP caching primitives - ETag, Last-Modified, Cache-Control - #678
Conversation
…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
…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
…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
9e98f19 to
9f02293
Compare
There was a problem hiding this comment.
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
RequestContexthelpers (etag(),lastModified(),cacheControl(),toHTTPDate()) and anisNoExecution()predicate for safe early-exit signaling. - Added
Responsefluent header helpers (withETag(),withCacheControl()) plus unit tests for the new behaviors. - Extended Event Caching metadata (
HandlerService) and integrated conditional-GET handling intoBootstrapcache-hit/cache-write paths; updatedRestHandlerto honorisNoExecution().
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.
| 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 | ||
| ); | ||
| } |
| 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; | ||
| } |
| * @value The date/time to format. Assumed to already be in the desired output timezone - this function does no conversion of its own. | ||
| */ |
| 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" ); |
| **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. |
| // 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
|
Thanks for the review — all six findings were real and are fixed in 3e504d9:
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 |
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 newisNoExecution()predicate, and a portabletoHTTPDate()formatter (built from individual date parts rather than adateTimeFormat()mask, since CFML's classic mask letters and Java'sDateTimeFormatterpattern 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-Matchmay be*or a comma-separated list and is always compared weakly, and a request carryingIf-None-MatchignoresIf-Modified-Sinceentirely per §3.3.Response: matchingwithETag()/withCacheControl()fluent helpers.HandlerService: newetag/etagWeak/lastModified/cacheControlannotations alongside the existingcache/cacheTimeoutevent-caching annotations. No separate on/off switch - these annotations are read unconditionally, gated only by the existingcache="true"on the action, exactly likecacheInclude/cacheExclude/cacheFilteralready are.Bootstrap: on a cache write, computes an ETag hash and/or Last-Modified timestamp once and stores it (plus theetagWeakflag) on the cache entry; on a cache hit, replays them through the sameevent.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 bare304. A handler that never sets the new annotations sees no behavior change.RestHandler:aroundHandlernow also guards onisNoExecution(), so a conditional-GET resolved inside an action doesn't hit the same write-after-commit hazard the SSEisSSE()guard already covers.Two real bugs were found and fixed while writing tests against actual execution rather than trusting the code:
cacheControl()'sisBoolean(val)check is loosely true for any castable value (isBoolean(60)istruein CFML/BoxLang), which silently dropped numeric directive values likemax-age=60down to a bare"max-age"token; and the originaldateTimeFormat()mask threw"Too many pattern letters: d"on BoxLang, which is what led to the portabletoHTTPDate()implementation.A Copilot review pass on this PR additionally found:
If-None-Matchmatching was exact-string-only (missing*/lists/weak comparison);If-Modified-Sincewasn't ignored whenIf-None-Matchwas 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 reusingetag()/lastModified(); and theetagWeakflag 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),Responsefluent header tests,RestHandler.aroundHandlerguard tests, and integration tests extending the existingEventCachingSpecwith two new test-harness handler actions. The integration suite could not be executed in the local sandbox used for this work (BaseIntegrationTestneeds 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-existingRestHandlerTestsandbox-limitation errors.Jira Issues
COLDBOX-1415
Type of change
(The spec doc in this same PR is the documentation update.)
Checklist