Skip to content

Commit 1cf550b

Browse files
committed
fix: implement cachableContentType, default to dynaic only
1 parent 7f81ce4 commit 1cf550b

21 files changed

Lines changed: 544 additions & 114 deletions

README.md

Lines changed: 121 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ It is designed for Next.js/Nuxt.js and other dynamic origins where latency and o
1717
- Built-in Basic-Auth dashboard for stats, charts, and invalidation
1818
- Warmup and sitemap discovery to reduce cold-start misses
1919
- Explicit response marker `X-Wait0` (`hit`, `miss`, `bypass`, etc.)
20+
- Diagnostic `X-Wait0-Reason` header when a response is not cacheable
2021

2122
## Quick Start
2223

@@ -34,13 +35,16 @@ server:
3435
origin: 'http://localhost:8080'
3536

3637
rules:
37-
- match: PathPrefix(/)
38+
- match: PathPrefix(/blog)
3839
priority: 1
40+
varyByQueryParams: ['page']
41+
# Stale-after signal: serve cached content, then refresh in background.
3942
expiration: '1m'
4043

41-
- match: PathPrefix(/blog)
44+
- match: PathPrefix(/)
4245
priority: 2
43-
varyByQueryParams: ['page']
46+
# Defaults to HTML and XHTML, keeping static assets out of wait0.
47+
cachableContentType: ['text/html', 'application/xhtml+xml']
4448
expiration: '1m'
4549
```
4650
@@ -129,29 +133,127 @@ Notes:
129133

130134
| Guide | Description |
131135
|-------|-------------|
136+
| [Usage and configuration](#usage-and-configuration) | [How wait0 works](#how-wait0-works), [query parameters](#query-parameter-caching), [sitemap warmup](#sitemap-warmup), and the [config reference](#config-file-reference) |
132137
| [For Developers](docs/for-developers.md) | Build/test commands, config reference, runtime options |
133138
| [API Endpoints](docs/api-endpoints.md) | Proxy behavior, invalidation API, schemas, status codes |
134139

135-
## Query-Aware Cache Keys
140+
## Usage and configuration
141+
142+
### How wait0 works
143+
144+
wait0 checks RAM, then disk, and waits for the origin only on a cache miss. A cached response is returned immediately. **Even when it is older than `expiration`, wait0 serves it first and refreshes it asynchronously.** Therefore, `expiration: '1m'` is a stale-after signal, not a hard expiry or eviction.
145+
146+
wait0 caches only `GET` responses with a `2xx` status, an allowed `Content-Type`, and no `Cache-Control: no-cache` or `no-store` directive. `cachableContentType` defaults to `text/html` and `application/xhtml+xml`, including values with parameters such as `text/html; charset=utf-8`. This keeps wait0 focused on controllable dynamic SWR; static assets should normally be cached by a CDN, Nginx, and browser caching.
147+
148+
An origin response that fails these checks is served as `X-Wait0: bypass` without being stored, with `X-Wait0-Reason` explaining why. If background revalidation receives a disallowed content type, either cache-control directive, or a non-`2xx` status, the existing entry is deleted; a network error leaves it available. A stale response is reported as `X-Wait0: hit`.
149+
150+
### Query parameter caching
151+
152+
Cache keys use only the URL path by default, so `/blog`, `/blog?page=1`, and `/blog?utm_source=email` share the same cached response. The first cold request reaches the origin with its complete query and fills the shared entry; later background refreshes omit query parameters that are not in `varyByQueryParams`.
153+
154+
Use `varyByQueryParams` to explicitly choose the parameters that must create different entries:
155+
156+
```yaml
157+
varyByQueryParams: ['page', 'lang']
158+
```
159+
160+
With this setting, `/blog?page=1` and `/blog?page=2` are different cache entries, while an unlisted parameter such as `utm_source` is ignored for cache identity and background refreshes. Allowed parameter names and values are normalized into a stable order.
136161

137-
Use `varyByQueryParams` on a rule when only a small set of query parameters should affect cache identity.
162+
### Sitemap warmup
138163

139-
Example:
164+
`urlsDiscover` reads sitemap files and registers their paths in the disk cache. It supports sitemap indexes, gzip sitemaps, absolute URLs, and origin-relative paths. Discovery does not fetch each page body by itself: add `warmUp` to a matching rule to fetch and periodically refresh the discovered paths. Paths with no matching rule, or a rule with `bypass: true`, are ignored.
165+
166+
### Config file reference
167+
168+
This example contains every current configuration option. Durations use Go syntax such as `10s`, `1m`, or `2h`; sizes accept bytes or `k`, `m`, and `g` suffixes.
140169

141170
```yaml
171+
storage:
172+
ram:
173+
# Maximum RAM cache size.
174+
max: '100m'
175+
disk:
176+
# Maximum LevelDB cache size.
177+
max: '1g'
178+
179+
server:
180+
# HTTP port; defaults to 8080 when omitted or set to 0.
181+
port: 8082
182+
# Required origin base URL.
183+
origin: 'http://host.docker.internal:8080'
184+
185+
invalidation:
186+
# Enables POST /wait0/invalidate.
187+
enabled: true
188+
# Maximum number of waiting asynchronous invalidation jobs; default 128.
189+
queue_size: 128
190+
# Number of invalidation workers; default 4.
191+
worker_concurrency: 4
192+
# Maximum invalidation request body in bytes; default 1 MiB.
193+
max_body_bytes: 1048576
194+
# Recommended maximum normalized paths per request; default 1024.
195+
max_paths_per_request: 1024
196+
# Recommended maximum normalized tags per request; default 1024.
197+
max_tags_per_request: 1024
198+
# true rejects requests over path/tag limits; false accepts and logs them.
199+
hard_limits: false
200+
201+
auth:
202+
tokens:
203+
# Unique name used for authentication and audit logs.
204+
- id: 'backoffice'
205+
# Optional inline secret/fallback; prefer token_env in production.
206+
token: 'replace-me'
207+
# Environment variable whose non-empty value overrides token.
208+
token_env: 'WAIT0_API_AUTH_TOKEN'
209+
# invalidation:write protects invalidation; stats:read protects stats.
210+
scopes: ['invalidation:write', 'stats:read']
211+
212+
urlsDiscover:
213+
# Delay before the first sitemap scan; 0s scans immediately.
214+
initialDelay: '20s'
215+
# Rescan interval; omit to scan only once.
216+
rediscoverEvery: '10m'
217+
# Sitemap or sitemap-index URLs; origin-relative paths are also accepted.
218+
sitemaps:
219+
- 'https://example.com/sitemap.xml'
220+
142221
rules:
143-
- match: PathPrefix(/blog)
222+
# Matches path prefixes; combine alternatives with |.
223+
- match: PathPrefix(/api) | PathPrefix(/admin)
224+
# Lower priority is evaluated first; the first matching rule wins.
144225
priority: 1
145-
varyByQueryParams: ['page']
226+
# Skips cache lookup and storage for matching requests.
227+
bypass: true
228+
229+
- match: PathPrefix(/)
230+
priority: 2
231+
# Bypasses cache when any named cookie is present.
232+
bypassWhenCookies: ['sessionid']
233+
# Exact media types eligible for wait0 caching. Parameters such as charset
234+
# are ignored. Defaults to the two values shown here.
235+
cachableContentType: ['text/html', 'application/xhtml+xml']
236+
# Only these query parameters become part of the cache key.
237+
varyByQueryParams: ['page', 'lang']
238+
# Stale-after age: serve cached data immediately, then refresh async;
239+
# omit to disable request-triggered age refresh.
146240
expiration: '1m'
241+
warmUp:
242+
# Refresh all known matching paths at this interval, regardless of age.
243+
runEvery: '10m'
244+
# Maximum concurrent requests for this warmup rule.
245+
maxRequestsAtATime: 20
246+
247+
logging:
248+
# Logs a stats snapshot at this interval; omit to disable.
249+
log_stats_every: '1m'
250+
# Logs a summary after each warmup batch.
251+
log_warmup: true
252+
# Logs details for each scanned sitemap.
253+
log_url_autodiscover: true
147254
```
148255

149-
With that rule:
150-
151-
- `/blog` and `/blog?utm_source=newsletter` use the same cache entry because `utm_source` is ignored.
152-
- `/blog?page` uses a different cache entry from `/blog`.
153-
- `/blog?page=1` uses a different cache entry from both `/blog` and `/blog?page`.
154-
- If multiple values are present for an allowed parameter, wait0 canonicalizes them into a stable key order.
256+
Compatibility-only options are still accepted but should not be used in new files: `urlsDiscover.initalDelay` is the old spelling of `initialDelay`, `logging.log_revalidation_every` is replaced by `log_warmup`, and `server.invalidation.tokens` is replaced by top-level `auth.tokens`.
155257

156258
## Redeploy Note
157259

@@ -163,7 +265,7 @@ If stale HTML is still cached, clients can receive pages that point to missing a
163265
To reduce this risk, `wait0` clears disk cache on startup by default (`WAIT0_INVALIDATE_DISK_CACHE_ON_START=true`).
164266
That behavior makes rollout safer because old HTML is not reused across deploy generations.
165267

166-
Hpowever, during redeploy you still need to restart wait0 by explicitly orchestrating it (for example, docker restart wait0).
268+
However, during redeploy you still need to restart wait0 by explicitly orchestrating it (for example, `docker restart wait0`).
167269

168270
If you do not restart between deploys, proactively refresh cache using invalidation and warmup for critical routes.
169271

@@ -174,7 +276,8 @@ If you do not restart between deploys, proactively refresh cache using invalidat
174276
- Rule field `varyByQueryParams[]` opts specific query parameters into cache identity for matching paths.
175277
- Query parameters not listed in `varyByQueryParams[]` and all fragments are ignored for cache identity.
176278
- Only `GET` requests are cache candidates.
177-
- Only origin `2xx` responses are stored.
178-
- For stale entries (rule `expiration`), wait0 serves cached response immediately and revalidates asynchronously.
179-
- For origin non-`2xx`, wait0 skips caching and evicts any existing key for that path.
279+
- Only origin `2xx` responses matching the rule's `cachableContentType` allowlist are stored.
280+
- `cachableContentType` defaults to HTML and XHTML; use CDN, reverse-proxy, and browser caches for static assets.
281+
- Rule `expiration` marks entries stale but does not evict them; stale responses are served immediately and revalidation is scheduled on a best-effort basis.
282+
- On a cache-path miss or revalidation, an origin non-`2xx` is not cached and any existing key is evicted.
180283
- Invalidation is asynchronous: accept request -> resolve keys by `paths` and `tags` -> delete keys -> recrawl in background. Path invalidation clears all cached query-aware variants for that path.

docs/api-endpoints.md

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,28 +28,34 @@ This is the default request path handled by the proxy controller.
2828

2929
## Behavior
3030

31-
| Condition | Result | `X-Wait0` |
32-
|----------|--------|-----------|
33-
| Matching rule has `bypass: true` | Forward to origin, no cache write | `bypass` |
34-
| Matching rule cookie bypass is triggered | Forward to origin, no cache write | `ignore-by-cookie` |
35-
| Method is not `GET` | Forward to origin, no cache write | `bypass` |
36-
| RAM or disk hit for active entry | Serve cached response instantly | `hit` |
37-
| Miss and cacheable origin `2xx` | Store and serve response | `miss` |
38-
| Origin non-`2xx` | Do not cache, evict existing key | `ignore-by-status` |
39-
| Origin fetch/network failure | Gateway error | `bad-gateway` |
31+
| Condition | Result | `X-Wait0` | `X-Wait0-Reason` |
32+
|----------|--------|-----------|------------------|
33+
| Matching rule has `bypass: true` | Skip cache; fetch upstream with bodyless `GET` | `bypass` | `bypass-rule` |
34+
| Matching rule cookie bypass is triggered | Skip cache; fetch upstream with bodyless `GET` | `ignore-by-cookie` | `bypass-cookie` |
35+
| Method is not `GET` | Skip cache; convert to bodyless upstream `GET` | `bypass` | `non-get-method` |
36+
| RAM or disk hit for active entry | Serve cached response instantly | `hit` | absent |
37+
| Miss and cacheable origin `2xx` | Store and serve response | `miss` | absent |
38+
| Origin `2xx` has disallowed `Content-Type` | Serve without storing | `bypass` | `non-cacheable-content-type` |
39+
| Origin `2xx` has `no-cache` or `no-store` | Serve without storing | `bypass` | `non-cacheable-cache-control` |
40+
| Origin non-`2xx` | Do not cache, evict existing key | `ignore-by-status` | `non-cacheable-status` |
41+
| Origin fetch/network failure | Gateway error | `bad-gateway` | `origin-error` |
4042

4143
## Cacheability rule
4244

4345
An origin response is cacheable only when:
4446

4547
- status is `2xx`, and
48+
- the media type in `Content-Type` appears in the matching rule's `cachableContentType` list, and
4649
- `Cache-Control` does not include `no-store` or `no-cache`.
4750

51+
`cachableContentType` defaults to `text/html` and `application/xhtml+xml`. Matching is case-insensitive and ignores media-type parameters such as `charset=utf-8`. A missing or malformed `Content-Type` is not cacheable.
52+
4853
## Response headers added by wait0
4954

5055
| Header | When present | Meaning |
5156
|--------|--------------|---------|
5257
| `X-Wait0` | always on handled responses | Cache/proxy decision marker |
58+
| `X-Wait0-Reason` | response bypassed or failed | Machine-readable reason the response was not cached |
5359
| `X-Wait0-Revalidated-At` | cache `hit` with revalidation metadata | Last revalidation timestamp (RFC3339Nano) |
5460
| `X-Wait0-Revalidated-By` | with `X-Wait0-Revalidated-At` | Revalidation source (`user`, `warmup`, `invalidate`, etc.) |
5561
| `X-Wait0-Discovered-By` | if entry was discovery seeded | Discovery source marker |

docs/for-developers.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ For dashboard:
128128
| `priority` | no | Rules are sorted ascending by priority |
129129
| `bypass` | no | For matching paths, bypass cache completely |
130130
| `bypassWhenCookies[]` | no | If any listed cookie exists, bypass cache |
131+
| `cachableContentType[]` | no | Exact cache-eligible media types; defaults to `text/html` and `application/xhtml+xml`; parameters are ignored |
131132
| `varyByQueryParams[]` | no | Query params that should participate in cache identity for matching paths |
132133
| `expiration` | no | Duration for stale check and async revalidation |
133134
| `warmUp.runEvery` | with `warmUp` | Duration, must be `> 0` |
@@ -157,9 +158,13 @@ For dashboard:
157158
- Rule field `varyByQueryParams[]` opt-ins selected query params so `/a/b?page`, `/a/b?page=1`, and `/a/b` can be distinct cache entries.
158159
- Query params not listed in `varyByQueryParams[]` and all fragments are ignored for cache identity.
159160
- Only `GET` requests are cache-eligible.
161+
- Bypassed and non-`GET` requests are sent upstream as `GET` without the original body.
160162
- Non-2xx origin responses are not cached and existing cached key is removed.
161-
- Dynamic pages are expected to send `Cache-Control: no-cache` or `no-store` so wait0 treats them as passthrough and revalidation-managed.
163+
- Origin responses whose media type is not in `cachableContentType` are served but not stored; background revalidation deletes an existing entry if its new media type is disallowed.
164+
- We recommend to Keep static assets in CDN, Nginx, and browser caches; wait0 defaults to caching only dynamic HTML/XHTML SWR responses.
165+
- Origin `2xx` responses with `Cache-Control: no-cache` or `no-store` are not stored; either directive received during revalidation deletes the existing entry.
162166
- `X-Wait0` response header identifies behavior (`hit`, `miss`, `bypass`, `ignore-by-cookie`, `ignore-by-status`, `bad-gateway`).
167+
- `X-Wait0-Reason` identifies why a response was bypassed or failed (for example, `bypass-rule` or `non-cacheable-content-type`).
163168

164169
## See Also
165170

internal/wait0/config.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,14 @@ type WarmUpConfig struct {
100100
}
101101

102102
type Rule struct {
103-
Match string `yaml:"match"`
104-
Priority int `yaml:"priority"`
105-
Bypass bool `yaml:"bypass"`
106-
BypassWhenCookies []string `yaml:"bypassWhenCookies"`
107-
VaryByQueryParams []string `yaml:"varyByQueryParams"`
108-
Expiration string `yaml:"expiration"`
109-
WarmUp *WarmUpConfig `yaml:"warmUp"`
103+
Match string `yaml:"match"`
104+
Priority int `yaml:"priority"`
105+
Bypass bool `yaml:"bypass"`
106+
BypassWhenCookies []string `yaml:"bypassWhenCookies"`
107+
CachableContentTypes []string `yaml:"cachableContentType"`
108+
VaryByQueryParams []string `yaml:"varyByQueryParams"`
109+
Expiration string `yaml:"expiration"`
110+
WarmUp *WarmUpConfig `yaml:"warmUp"`
110111

111112
// compiled
112113
matchers []pathPrefixMatcher
@@ -205,6 +206,11 @@ func LoadConfig(path string) (Config, error) {
205206
return Config{}, fmt.Errorf("rules[%d].match: %w", i, err)
206207
}
207208
r.matchers = ms
209+
contentTypes, err := proxy.NormalizeCachableContentTypes(r.CachableContentTypes)
210+
if err != nil {
211+
return Config{}, fmt.Errorf("rules[%d].cachableContentType: %w", i, err)
212+
}
213+
r.CachableContentTypes = contentTypes
208214
if len(r.VaryByQueryParams) > 0 {
209215
params, err := proxy.NormalizeVaryByQueryParams(r.VaryByQueryParams)
210216
if err != nil {

internal/wait0/config_test.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ rules:
3232
bypass: true
3333
- match: "PathPrefix(/)"
3434
priority: 1
35-
varyByQueryParams: [" page ", "lang", "page"]
35+
cachableContentType: [" Application/JSON; Charset=UTF-8 ", "application/json"]
36+
varyByQueryParams: [" page ", "lang", "page"]
3637
expiration: "30s"
3738
warmUp:
3839
runEvery: "1m"
@@ -77,6 +78,18 @@ rules:
7778
t.Fatalf("varyByQueryParams[%d] = %q, want %q", i, cfg.Rules[0].VaryByQueryParams[i], wantQueryParams[i])
7879
}
7980
}
81+
if got := cfg.Rules[0].CachableContentTypes; len(got) != 1 || got[0] != "application/json" {
82+
t.Fatalf("cachableContentType = %v, want [application/json]", got)
83+
}
84+
wantDefaultContentTypes := []string{"text/html", "application/xhtml+xml"}
85+
if got := cfg.Rules[1].CachableContentTypes; len(got) != len(wantDefaultContentTypes) {
86+
t.Fatalf("default cachableContentType = %v, want %v", got, wantDefaultContentTypes)
87+
}
88+
for i, want := range wantDefaultContentTypes {
89+
if got := cfg.Rules[1].CachableContentTypes[i]; got != want {
90+
t.Fatalf("default cachableContentType[%d] = %q, want %q", i, got, want)
91+
}
92+
}
8093
if cfg.Rules[0].warmEvery != time.Minute || cfg.Rules[0].warmMax != 3 {
8194
t.Fatalf("warmup compiled fields not set")
8295
}
@@ -90,6 +103,7 @@ func TestLoadConfig_Errors(t *testing.T) {
90103
{name: "missing origin", yaml: "storage:\n ram: {max: \"1m\"}\n disk: {max: \"1m\"}\nserver:\n port: 8080\nrules: []\n"},
91104
{name: "bad match", yaml: "storage:\n ram: {max: \"1m\"}\n disk: {max: \"1m\"}\nserver:\n origin: \"http://x\"\nrules:\n - match: \"BadExpr(/)\"\n"},
92105
{name: "bad varyByQueryParams", yaml: "storage:\n ram: {max: \"1m\"}\n disk: {max: \"1m\"}\nserver:\n origin: \"http://x\"\nrules:\n - match: \"PathPrefix(/)\"\n varyByQueryParams: [\"page\", \" \" ]\n"},
106+
{name: "bad cachableContentType", yaml: "storage:\n ram: {max: \"1m\"}\n disk: {max: \"1m\"}\nserver:\n origin: \"http://x\"\nrules:\n - match: \"PathPrefix(/)\"\n cachableContentType: [\"not a content type\"]\n"},
93107
{name: "bad warmup", yaml: "storage:\n ram: {max: \"1m\"}\n disk: {max: \"1m\"}\nserver:\n origin: \"http://x\"\nrules:\n - match: \"PathPrefix(/)\"\n warmUp:\n runEvery: \"\"\n maxRequestsAtATime: 1\n"},
94108
{name: "bad log stats", yaml: "storage:\n ram: {max: \"1m\"}\n disk: {max: \"1m\"}\nserver:\n origin: \"http://x\"\nlogging:\n log_stats_every: \"bad\"\nrules: []\n"},
95109
{name: "duplicate auth token ids", yaml: "storage:\n ram: {max: \"1m\"}\n disk: {max: \"1m\"}\nserver:\n origin: \"http://x\"\n invalidation:\n enabled: true\nauth:\n tokens:\n - id: \"dup\"\n token: \"a\"\n scopes: [\"invalidation:write\"]\n - id: \"dup\"\n token: \"b\"\n scopes: [\"invalidation:write\"]\nrules: []\n"},

0 commit comments

Comments
 (0)