zuplo 7.0.0 → 7.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/docs/articles/ci-cd-azure/basic-deployment.mdx +2 -2
  2. package/docs/articles/ci-cd-azure/deploy-and-test.mdx +1 -1
  3. package/docs/articles/ci-cd-azure/local-testing.mdx +2 -2
  4. package/docs/articles/ci-cd-azure/multi-stage-deployment.mdx +3 -3
  5. package/docs/articles/ci-cd-azure/pr-preview-environments.mdx +1 -1
  6. package/docs/articles/ci-cd-azure/tag-based-releases.mdx +1 -1
  7. package/docs/articles/ci-cd-bitbucket/basic-deployment.mdx +1 -1
  8. package/docs/articles/ci-cd-bitbucket/deploy-and-test.mdx +1 -1
  9. package/docs/articles/ci-cd-bitbucket/local-testing.mdx +1 -1
  10. package/docs/articles/ci-cd-bitbucket/multi-stage-deployment.mdx +1 -1
  11. package/docs/articles/ci-cd-bitbucket/pr-preview-environments.mdx +1 -1
  12. package/docs/articles/ci-cd-bitbucket/tag-based-releases.mdx +1 -1
  13. package/docs/articles/ci-cd-circleci/basic-deployment.mdx +1 -1
  14. package/docs/articles/ci-cd-circleci/deploy-and-test.mdx +1 -1
  15. package/docs/articles/ci-cd-circleci/local-testing.mdx +2 -2
  16. package/docs/articles/ci-cd-circleci/multi-stage-deployment.mdx +3 -3
  17. package/docs/articles/ci-cd-circleci/pr-preview-environments.mdx +1 -1
  18. package/docs/articles/ci-cd-circleci/tag-based-releases.mdx +1 -1
  19. package/docs/articles/ci-cd-github/basic-deployment.mdx +1 -1
  20. package/docs/articles/ci-cd-github/cleanup-on-branch-delete.mdx +2 -2
  21. package/docs/articles/ci-cd-github/deploy-and-test.mdx +1 -1
  22. package/docs/articles/ci-cd-github/local-testing.mdx +2 -2
  23. package/docs/articles/ci-cd-github/multi-stage-deployment.mdx +3 -3
  24. package/docs/articles/ci-cd-github/pr-preview-environments.mdx +2 -2
  25. package/docs/articles/ci-cd-github/tag-based-releases.mdx +1 -1
  26. package/docs/articles/ci-cd-gitlab/basic-deployment.mdx +1 -1
  27. package/docs/articles/ci-cd-gitlab/deploy-and-test.mdx +1 -1
  28. package/docs/articles/ci-cd-gitlab/local-testing.mdx +1 -1
  29. package/docs/articles/ci-cd-gitlab/mr-preview-environments.mdx +1 -1
  30. package/docs/articles/ci-cd-gitlab/multi-stage-deployment.mdx +1 -1
  31. package/docs/articles/ci-cd-gitlab/tag-based-releases.mdx +1 -1
  32. package/docs/articles/github-deployment-testing.mdx +1 -1
  33. package/docs/articles/graphql-caching.mdx +2 -0
  34. package/docs/articles/local-development.mdx +4 -4
  35. package/docs/articles/monorepo-deployment.mdx +3 -3
  36. package/docs/articles/sharing-code-across-projects.mdx +2 -2
  37. package/docs/articles/step-1-setup-basic-gateway-local.mdx +1 -1
  38. package/docs/articles/testing.mdx +5 -5
  39. package/docs/articles/troubleshooting-slow-responses.mdx +5 -0
  40. package/docs/articles/update-zup-in-github-action.mdx +1 -1
  41. package/docs/caching/cdn-caching.mdx +397 -0
  42. package/docs/caching/custom-caching-policy.mdx +426 -0
  43. package/docs/caching/dynamic-cache-rules.mdx +427 -0
  44. package/docs/caching/gateway-caching.mdx +308 -0
  45. package/docs/caching/overview.mdx +150 -0
  46. package/docs/caching/partial-response-caching.mdx +641 -0
  47. package/docs/cli/overview.mdx +2 -3
  48. package/docs/dedicated/akamai/caching.mdx +245 -140
  49. package/docs/dev-portal/local-development.mdx +1 -1
  50. package/docs/dev-portal/migration.mdx +1 -1
  51. package/docs/mcp-gateway/quickstart-local.mdx +1 -1
  52. package/docs/policies/api-key-inbound/schema.json +4 -2
  53. package/docs/programmable-api/cache.mdx +7 -0
  54. package/docs/programmable-api/memory-zone-read-through-cache.mdx +2 -0
  55. package/docs/programmable-api/streaming-zone-cache.mdx +2 -0
  56. package/docs/programmable-api/zone-cache.mdx +7 -0
  57. package/package.json +5 -5
@@ -0,0 +1,308 @@
1
+ ---
2
+ title: Cache at the gateway
3
+ description:
4
+ Use the Caching policy to store responses inside the Zuplo gateway so repeat
5
+ requests never reach your backend. Covers cache keys, TTLs, authorization, and
6
+ invalidation.
7
+ ---
8
+
9
+ The Caching policy (`caching-inbound`) stores a response inside the gateway and
10
+ replays it for the next identical request. On a hit the policy answers from the
11
+ inbound pipeline and the backend never sees the request at all, which is the
12
+ whole point when the backend is the slow, rate-limited, or expensive part of the
13
+ system.
14
+
15
+ ## When the gateway cache is the right layer
16
+
17
+ Gateway caching earns its place in three situations.
18
+
19
+ **No CDN sits in front of the gateway.** Traffic goes straight to Zuplo, so the
20
+ gateway is the only place a response can be reused. This is the common case for
21
+ internal APIs, partner APIs, and anything served directly from a Zuplo domain.
22
+
23
+ **The backend is slow, rate-limited, or billed per call.** A catalog search
24
+ endpoint that takes 900 ms of database time, or an upstream provider that allows
25
+ 10 requests per second and charges per lookup, is worth protecting even when a
26
+ CDN also caches. A 60-second TTL on an endpoint receiving 200 requests per
27
+ minute turns 12,000 backend calls per hour into 60.
28
+
29
+ **The response must not leave the gateway boundary.** Data that policy or
30
+ compliance keeps off a third-party edge network can still be cached, because the
31
+ entry lives inside your Zuplo environment rather than in CDN storage.
32
+
33
+ :::tip
34
+
35
+ If a CDN already fronts your gateway, cache there first. A CDN hit is served
36
+ from a point of presence near the caller and never touches Zuplo at all, so it
37
+ is both cheaper and faster than a gateway hit. See
38
+ [Cache at the CDN](./cdn-caching.mdx) for the `cdn-cache-control-outbound`
39
+ policy. Use the gateway cache for what the CDN can't cover, not as a duplicate
40
+ layer.
41
+
42
+ :::
43
+
44
+ ## How it works
45
+
46
+ The policy builds a cache key from the request, looks it up, and either answers
47
+ immediately or lets the request continue to the handler and stores the result on
48
+ the way out. By default the key covers the request method, the URL path, and the
49
+ query parameters, plus the `Authorization` header, any headers you name, and the
50
+ `cacheId` value. Two requests share an entry only when every one of those parts
51
+ matches.
52
+
53
+ <Diagram type="sequence" height="h-[420px]">
54
+ <DiagramActor id="client" variant="yellow">
55
+ Client
56
+ </DiagramActor>
57
+ <DiagramActor id="policy" variant="zuplo">
58
+ Caching policy
59
+ </DiagramActor>
60
+ <DiagramActor id="backend" variant="green">
61
+ Backend
62
+ </DiagramActor>
63
+ <DiagramMessage from="client" to="policy">
64
+ GET /products?page=1 (miss)
65
+ </DiagramMessage>
66
+ <DiagramMessage from="policy" to="backend">
67
+ Forward request
68
+ </DiagramMessage>
69
+ <DiagramMessage from="backend" to="policy">
70
+ 200 OK
71
+ </DiagramMessage>
72
+ <DiagramMessage from="policy" to="client">
73
+ 200 OK, response stored
74
+ </DiagramMessage>
75
+ <DiagramMessage from="client" to="policy">
76
+ GET /products?page=1 (hit)
77
+ </DiagramMessage>
78
+ <DiagramMessage from="policy" to="client">
79
+ 200 OK from cache
80
+ </DiagramMessage>
81
+ </Diagram>
82
+
83
+ The second request never reaches the backend. Neither does anything else the
84
+ route would normally do after the caching policy: a hit short-circuits the rest
85
+ of the pipeline.
86
+
87
+ ## Add the policy to a route
88
+
89
+ Add the policy to `config/policies.json`. This example caches `GET` responses
90
+ for five minutes and keys them by locale.
91
+
92
+ ```json title="config/policies.json"
93
+ {
94
+ "policies": [
95
+ {
96
+ "name": "product-cache",
97
+ "policyType": "caching-inbound",
98
+ "handler": {
99
+ "export": "CachingInboundPolicy",
100
+ "module": "$import(@zuplo/runtime)",
101
+ "options": {
102
+ "expirationSecondsTtl": 300,
103
+ "cacheHttpMethods": ["GET"],
104
+ "statusCodes": [200, 404],
105
+ "headers": ["Accept-Language"],
106
+ "cacheId": "$env(CACHE_ID)",
107
+ "dangerouslyIgnoreAuthorizationHeader": false
108
+ }
109
+ }
110
+ }
111
+ ]
112
+ }
113
+ ```
114
+
115
+ Every option is optional. The defaults are:
116
+
117
+ | Option | Default | What it controls |
118
+ | -------------------------------------- | ------------------------------------- | ------------------------------------------------ |
119
+ | `expirationSecondsTtl` | `60` | How long an entry stays valid, in seconds |
120
+ | `cacheHttpMethods` | `["GET"]` | Which methods are cached |
121
+ | `statusCodes` | `[200, 206, 301, 302, 303, 404, 410]` | Which response status codes are stored |
122
+ | `headers` | `[]` | Extra request headers folded into the cache key |
123
+ | `cacheId` | none | An arbitrary string in the key, used for busting |
124
+ | `dangerouslyIgnoreAuthorizationHeader` | `false` | Whether to drop `Authorization` from the key |
125
+
126
+ `cacheHttpMethods` accepts `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and `HEAD`.
127
+ Caching a method that changes state is almost always a mistake; leave the
128
+ default unless you have a read-only `POST` endpoint such as a search query.
129
+
130
+ Then reference the policy by name on the route:
131
+
132
+ ```json title="config/routes.oas.json"
133
+ {
134
+ "paths": {
135
+ "/products": {
136
+ "get": {
137
+ "operationId": "list-products",
138
+ "x-zuplo-route": {
139
+ "handler": {
140
+ "export": "urlForwardHandler",
141
+ "module": "$import(@zuplo/runtime)",
142
+ "options": { "baseUrl": "https://api.example.com" }
143
+ },
144
+ "policies": { "inbound": ["product-cache"] }
145
+ }
146
+ }
147
+ }
148
+ }
149
+ }
150
+ ```
151
+
152
+ The string in `policies.inbound` must match the policy's `name` exactly. Put the
153
+ caching policy after authentication policies so that unauthenticated requests
154
+ are rejected before they can read from or write to the cache.
155
+
156
+ ## Shape the cache key with headers
157
+
158
+ The `headers` option adds request headers to the key. Use it when the backend
159
+ returns a genuinely different body for different values of that header.
160
+
161
+ **`Accept`** — an endpoint that serves both `application/json` and `text/csv`
162
+ needs `"headers": ["Accept"]`. Without it, the first caller to ask for CSV
163
+ poisons the entry for every JSON caller for the rest of the TTL. Two media types
164
+ double the number of entries, which costs nothing.
165
+
166
+ **`Accept-Language`** — a catalog that localizes product names needs
167
+ `"headers": ["Accept-Language"]`. With five supported locales and 500 distinct
168
+ product URLs the cache holds 2,500 entries instead of 500, and the hit rate
169
+ within each locale is unchanged, because callers sharing a locale share an
170
+ entry.
171
+
172
+ Every header you add multiplies the key space by the number of distinct values
173
+ it carries in real traffic, which is why `User-Agent` is the classic mistake:
174
+ 50,000 daily callers present tens of thousands of distinct strings, so nearly
175
+ every entry is written once and read never. The hit rate collapses to near zero
176
+ and the cache becomes pure overhead. To vary by device, normalize the variation
177
+ into a handful of values in an earlier policy and key on that instead.
178
+
179
+ ## Cache authenticated responses
180
+
181
+ `dangerouslyIgnoreAuthorizationHeader` defaults to `false`, which means the
182
+ `Authorization` header is part of the cache key. Each bearer token or API key
183
+ gets its own entry, so one caller can never be served another caller's response.
184
+ Keep this default for anything user-specific.
185
+
186
+ Setting it to `true` removes `Authorization` from the key, and every authorized
187
+ caller shares one entry. That is legitimate in exactly one situation: the
188
+ response body is byte-identical for every caller who is allowed through — the
189
+ credential gates access but does not change the data. A shared reference table,
190
+ a public price list behind an API key, and a service status document all
191
+ qualify, and all benefit, because a single entry now absorbs traffic from every
192
+ consumer instead of one entry per token.
193
+
194
+ :::danger
195
+
196
+ If the response varies by caller in any way — a tenant ID in the payload, a
197
+ filtered result set, a personalized field — setting
198
+ `dangerouslyIgnoreAuthorizationHeader` to `true` serves the first caller's data
199
+ to everyone else on that route until the TTL expires. Nothing errors and nothing
200
+ logs; the disclosure is silent. Verify that two different authorized callers
201
+ receive identical bytes before you enable it.
202
+
203
+ :::
204
+
205
+ ## Invalidate with cacheId
206
+
207
+ `cacheId` is an arbitrary string folded into the cache key. Change the string
208
+ and every previously stored key becomes unreachable at once. Drive it from an
209
+ environment variable so you can change it without editing code:
210
+
211
+ ```json title="config/policies.json"
212
+ {
213
+ "options": {
214
+ "expirationSecondsTtl": 300,
215
+ "cacheId": "$env(CACHE_ID)"
216
+ }
217
+ }
218
+ ```
219
+
220
+ Set `CACHE_ID` to a timestamp such as `2026-08-03-14-05` in your
221
+ [environment variables](../articles/environment-variables.mdx). To flush, update
222
+ the variable to a new value and redeploy. Every key the policy computes now
223
+ carries the new string, so no request matches an old entry. The orphaned entries
224
+ stay in storage until their TTL expires, but nothing serves them. A deployment
225
+ on its own flushes nothing — only a change to the value does. Give each policy
226
+ that needs independent invalidation its own variable; policies that share a
227
+ variable flush together.
228
+
229
+ :::warning
230
+
231
+ This is the entire invalidation story for the gateway cache. No purge API
232
+ exists, and no way to evict a single key. Plan around it: pick a TTL you are
233
+ willing to serve stale data for, and treat `cacheId` as the emergency lever
234
+ rather than part of a normal write path.
235
+
236
+ :::
237
+
238
+ Clients can also bust their own reads by adding a version or timestamp query
239
+ parameter. Query parameters are part of the key by default, so a new value
240
+ misses and fetches fresh data.
241
+
242
+ ## Choose a TTL
243
+
244
+ `expirationSecondsTtl` defaults to 60 seconds. Pick a value from how fast the
245
+ underlying data actually changes, not from how fast you would like responses to
246
+ be — the TTL is the maximum staleness a caller can observe.
247
+
248
+ | Data volatility | TTL | Example endpoints |
249
+ | ------------------- | ------------- | ----------------------------------------------------------------------- |
250
+ | Static | 3600 s and up | `/v1/countries`, `/v1/currencies`, `/v1/plans`, published documentation |
251
+ | Semi-dynamic | 300–3600 s | `/v1/products/{id}`, `/v1/venues`, `/v1/categories`, marketing content |
252
+ | Frequently changing | 60–300 s | `/v1/search?q=`, `/v1/showtimes`, `/v1/inventory`, dashboards |
253
+
254
+ Anything that must reflect a write immediately — carts, seat holds, balances,
255
+ order status — does not belong in a response cache at any TTL. Start at the
256
+ short end of the range and lengthen it once you have measured the hit rate. A
257
+ TTL far below the interval between requests to a given key produces almost no
258
+ hits while still paying the write cost on every response.
259
+
260
+ ## Limitations
261
+
262
+ **Cache entries are local to a zone.** Each Zuplo data center keeps its own
263
+ copy, so the first request for a key in a given region is always a miss even
264
+ when the same key is warm elsewhere. Expect a lower hit rate on globally
265
+ distributed traffic than a single-region test suggests.
266
+
267
+ **There is no selective purge.** Entries expire on their TTL or become
268
+ unreachable when `cacheId` changes. No API evicts a single key.
269
+
270
+ **Streaming responses are not cached**, and very large bodies are a poor fit —
271
+ the gateway has to hold the whole response to store it.
272
+
273
+ **A hit skips the rest of the pipeline.** Outbound policies do not run when the
274
+ caching policy answers from storage, so an outbound policy cannot be the sole
275
+ author of a header that must appear on every response.
276
+
277
+ :::caution{title="Do not combine with cdn-cache-control-outbound"}
278
+
279
+ Never put `caching-inbound` and
280
+ [`cdn-cache-control-outbound`](../policies/cdn-cache-control-outbound.mdx) on
281
+ the same route. `caching-inbound` answers a hit from the inbound pipeline, which
282
+ short-circuits the route before outbound policies run, so the CDN policy never
283
+ executes on a hit. The CDN then receives the copy that `caching-inbound` stored,
284
+ and `caching-inbound` sanitizes every copy it stores: it drops several CDN cache
285
+ headers (exactly which ones depends on the CDN) and overwrites `Cache-Control`
286
+ with its own `s-maxage` (its TTL, 60 seconds by default). The edge and client
287
+ TTLs collapse into one number. Misses run the full outbound stack and look
288
+ completely correct, so the defect appears only once the cache warms, and nothing
289
+ logs it. Pick one layer per route.
290
+
291
+ :::
292
+
293
+ ## Specialized caches
294
+
295
+ Two policies key on payload shape rather than on bytes, and beat this policy on
296
+ the traffic they are built for:
297
+
298
+ - [GraphQL Cache](../policies/graphql-cache-inbound.mdx) normalizes a GraphQL
299
+ document and its variables before hashing, so two semantically identical
300
+ queries share an entry even when their bodies differ.
301
+ - [Semantic Cache](../policies/semantic-cache-inbound.mdx) matches requests by
302
+ meaning rather than exact text, which is what makes caching viable in front of
303
+ an LLM.
304
+
305
+ To cache a fragment of a response rather than all of it, or to drive the cache
306
+ from your own code, see
307
+ [Build a custom caching policy](./custom-caching-policy.mdx) and
308
+ [ZoneCache](../programmable-api/zone-cache.mdx).
@@ -0,0 +1,150 @@
1
+ ---
2
+ title: Caching in Zuplo
3
+ sidebar_label: Overview
4
+ description:
5
+ Compare the four places a response can be cached when Zuplo sits in front of
6
+ your API, what each layer costs on a miss, and how to pick the right one for a
7
+ given endpoint.
8
+ ---
9
+
10
+ A cached response is one your backend never serves. Every hit removes a database
11
+ query, a compute cycle, and an egress charge from your bill, and it returns to
12
+ the caller in a fraction of the time the real work takes.
13
+
14
+ The question is rarely _whether_ to cache, but _where_. The same response can be
15
+ cached in four different places, each with a different reach, a different
16
+ invalidation story, and a different price when it misses.
17
+
18
+ ## The four layers
19
+
20
+ When Zuplo is in the request path, a response can be held at any of these
21
+ points. They run in order from the client inward, and each one only pays for
22
+ itself when the layer in front of it misses.
23
+
24
+ <Diagram height="h-80">
25
+ <DiagramNode id="client">Client</DiagramNode>
26
+ <DiagramNode id="cdn" variant="blue">
27
+ CDN edge cache
28
+ </DiagramNode>
29
+ <DiagramGroup id="zuplo" label="Zuplo gateway">
30
+ <DiagramNode id="gateway" variant="zuplo">
31
+ Response cache
32
+ </DiagramNode>
33
+ <DiagramNode id="programmable" variant="zuplo">
34
+ Programmable cache
35
+ </DiagramNode>
36
+ </DiagramGroup>
37
+ <DiagramNode id="origin" variant="green">
38
+ Backend cache
39
+ </DiagramNode>
40
+ <DiagramEdge from="client" to="cdn" label="Request" />
41
+ <DiagramEdge from="cdn" to="gateway" label="Miss" />
42
+ <DiagramEdge
43
+ from="gateway"
44
+ to="programmable"
45
+ fromSide="bottom"
46
+ toSide="top"
47
+ label="Miss"
48
+ />
49
+ <DiagramEdge from="programmable" to="origin" label="Miss" />
50
+ </Diagram>
51
+
52
+ **1. The CDN edge cache** is closest to the client and furthest from the data.
53
+ It lives in a CDN you run in front of the gateway: Akamai, Fastly, Cloudflare,
54
+ or CloudFront. Zuplo doesn't operate this cache; the
55
+ [CDN Cache Control policy](../policies/cdn-cache-control-outbound.mdx) writes
56
+ the headers your CDN reads to decide how long to hold a response and which purge
57
+ tags it belongs to. A hit here never reaches your gateway at all, which makes it
58
+ the only layer that removes the request from your infrastructure entirely. A
59
+ miss costs a full trip to the gateway plus everything behind it.
60
+
61
+ **2. The gateway response cache** is the first thing inside Zuplo. The
62
+ [Caching policy](../policies/caching-inbound.mdx) stores whole responses keyed
63
+ by method, URL, query string, and (by default) the `Authorization` header, and
64
+ serves them from an inbound policy. A hit short-circuits the rest of the
65
+ pipeline: the handler, the backend, and every outbound policy. A miss costs the
66
+ full pipeline and a backend round trip.
67
+
68
+ **3. The programmable cache** lives inside your own code, in a handler or a
69
+ custom policy, through the [Cache API](../programmable-api/cache.mdx) or
70
+ [`ZoneCache`](../programmable-api/zone-cache.mdx). It's the only layer that can
71
+ cache something smaller than a response: a shared fragment of a payload, a
72
+ tenant lookup, a JWKS document. A miss costs only the work that one cached value
73
+ stood in for (one query, one fetch), not the whole request.
74
+
75
+ **4. The backend's own cache** is closest to the data and does nothing for the
76
+ network hops in front of it. By the time a request arrives, every layer above
77
+ has already paid its latency. A miss here costs the real work: the query, the
78
+ render, the computation. You build and run this one yourself; you configure the
79
+ three above it in Zuplo.
80
+
81
+ ## Which layer to use
82
+
83
+ Find the row that matches the endpoint in front of you. The rows are ordered
84
+ roughly by how much a hit saves.
85
+
86
+ | Situation | Layer | Mechanism | Where to read |
87
+ | ---------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------- |
88
+ | A public catalog endpoint that returns the same body to everyone | CDN edge | `cdn-cache-control-outbound` with an `edge` and a `client` TTL | [Cache at the CDN](./cdn-caching.mdx) |
89
+ | An authenticated endpoint whose body is identical for every caller | CDN edge | `cdn-cache-control-outbound` with `client.visibility: "private"` so only the CDN stores it | [Cache at the CDN](./cdn-caching.mdx) |
90
+ | A mostly shared response with a small caller-specific slice | Programmable | Cache the shared fragment with the Cache API or `ZoneCache`, fetch only the dynamic part | [Cache part of a response](./partial-response-caching.mdx) |
91
+ | A response whose cacheability depends on what it contains (empty results, error bodies, flags) | CDN edge, per response | A `cacheConfig` function that returns TTLs and purge tags for the response in hand | [Per-response cache rules](./dynamic-cache-rules.mdx) |
92
+ | Caching rules no built-in policy expresses (a JWT-claim key, a size limit, an upstream TTL) | Programmable | A custom inbound and outbound policy pair built on the Cache API | [Build a custom caching policy](./custom-caching-policy.mdx) |
93
+ | Identical responses served to many callers, with no CDN in front of the gateway | Gateway | `caching-inbound` with an `expirationSecondsTtl` | [Cache at the gateway](./gateway-caching.mdx) |
94
+ | A GraphQL query repeated across callers, where the cache key is the query and its variables | Gateway | `graphql-cache-inbound` | [GraphQL Cache policy](../policies/graphql-cache-inbound.mdx) |
95
+ | An LLM prompt worded differently each time but asking the same question | Gateway | `semantic-cache-inbound`, which matches on prompt similarity rather than an exact key | [Semantic Cache policy](../policies/semantic-cache-inbound.mdx) |
96
+
97
+ The intuitive answer is wrong in these cases:
98
+
99
+ - **An authenticated response doesn't have to skip the edge.** If the body is
100
+ the same for every caller and the `Authorization` header only decides
101
+ _whether_ the caller may have it, the CDN can hold one copy while browsers
102
+ hold none. A single `Cache-Control` header can't express that; a targeted edge
103
+ header can.
104
+ - **A cache that only holds whole responses can't help a payload split 90%
105
+ shared, 10% personal.** Caching the shared 90% and assembling the rest per
106
+ request turns a full-size backend call into a small one. That's the pattern
107
+ with the largest cost delta on most APIs, and it belongs in code.
108
+
109
+ ## Combining layers
110
+
111
+ **Put the CDN edge in front of the programmable cache.** This is the combination
112
+ worth reaching for first. The CDN absorbs the requests that are identical across
113
+ callers, and the programmable cache makes the requests that get through cheap.
114
+ The handler assembles a response from a cached shared fragment plus one small
115
+ backend call instead of one full-size call per request. The two layers touch
116
+ nothing in common: one sets response headers on the way out, the other reads and
117
+ writes keys inside your handler. A purge at the edge doesn't disturb the
118
+ programmable cache, and vice versa.
119
+
120
+ Stacking the gateway response cache with a programmable cache inside your
121
+ handler is also safe. A `caching-inbound` hit skips the handler, so the
122
+ programmable cache simply becomes the miss path.
123
+
124
+ :::caution{title="Never stack caching-inbound and cdn-cache-control-outbound"}
125
+
126
+ Pick one. A gateway cache hit is served from an inbound policy, and an inbound
127
+ policy that returns a response short-circuits the pipeline before any outbound
128
+ policy runs, so `cdn-cache-control-outbound` never executes on a hit. Your CDN
129
+ then receives the copy that `caching-inbound` stored, and `caching-inbound`
130
+ sanitizes every copy it stores: it drops several CDN cache headers (exactly
131
+ which ones depends on the CDN) and overwrites `Cache-Control` with its own
132
+ `s-maxage` (its TTL, 60 seconds by default). The edge and client TTLs collapse
133
+ into one number.
134
+
135
+ The failure is invisible on cache misses. Misses run the full outbound stack and
136
+ produce exactly the headers you configured, so the route looks correct in
137
+ testing and starts sending the wrong cache policy to your CDN only once the
138
+ gateway cache warms up. Nothing logs it.
139
+
140
+ :::
141
+
142
+ ## Where to go next
143
+
144
+ The table above links a guide for every layer you configure in Zuplo. For the
145
+ runtime reference behind the programmable cache, see
146
+ [`ZoneCache`](../programmable-api/zone-cache.mdx) for zone-local key-value
147
+ storage and the [Cache API](../programmable-api/cache.mdx) for whole
148
+ `Request`/`Response` pairs. Those two also cover the case the table leaves out:
149
+ expensive reference data such as configuration, tenant metadata, or a JWKS
150
+ document looked up on every request.