# Per-response cache rules

Static policy options answer one question: how should this route cache? That's
enough for a catalog endpoint that always caches for 30 minutes. It runs out
when the answer depends on the request or the response in front of you:

- This response is about city 42 and cinema 7, so it needs the purge tags
  `city-42` and `cinema-7`. A static `tags` array can't know the identifiers.
- This list endpoint returned zero rows because a backend dependency timed out.
  A 30 minute edge TTL turns a two second blip into a 30 minute outage.
- These screening times change in four minutes. Fifteen minutes at the edge is
  wrong for this response and right for the next one.

The `cacheConfig` option on the
[CDN Cache Control](../policies/cdn-cache-control-outbound.mdx) policy points at
a function that runs on the outbound response and decides those cases. Read
[Cache at the CDN](./cdn-caching.mdx) first for the static options and the
`edge`/`client` split; this page assumes them.

## The function returns intent, not headers

The function never writes a header. It returns an edge TTL, a client TTL, and a
list of purge tags, and the policy renders that intent into whichever dialect
the `cdn` option names.

<Diagram height="h-72">
  <DiagramNode id="fn" variant="zuplo">
    Function returns intent
  </DiagramNode>
  <DiagramNode id="akamai">Edge-Control</DiagramNode>
  <DiagramNode id="fastly">Surrogate-Control</DiagramNode>
  <DiagramNode id="cf">Cloudflare-CDN-Cache-Control</DiagramNode>
  <DiagramEdge from="fn" to="akamai" label="akamai" />
  <DiagramEdge from="fn" to="fastly" label="fastly" />
  <DiagramEdge from="fn" to="cf" label="cloudflare" />
</Diagram>

That separation keeps a CDN migration cheap. Tags joined by commas on Akamai are
joined by spaces on Fastly, and the header names differ on all three. A function
returning raw headers would need a rewrite; one returning
`tags: ["catalog", "city-42"]` keeps working when `cdn` changes, with the vendor
limits on tag length and charset still enforced for you.

```json title="config/policies.json"
{
  "name": "cdn-cache-control",
  "policyType": "cdn-cache-control-outbound",
  "handler": {
    "export": "CdnCacheControlOutboundPolicy",
    "module": "$import(@zuplo/runtime)",
    "options": {
      "cdn": "akamai",
      "strategy": "s-maxage",
      "edge": { "maxAge": 300, "staleIfError": 86400 },
      "client": { "maxAge": 60, "visibility": "public" },
      "tags": ["catalog"],
      "cacheConfig": {
        "module": "$import(./modules/cdn-cache)",
        "export": "default"
      }
    }
  }
}
```

The signature is
`(response: Response, request: ZuploRequest, context: ZuploContext)`, and the
function may be synchronous or async.

## What the return value means

| Return             | Effect                                                                              |
| ------------------ | ----------------------------------------------------------------------------------- |
| `undefined`/`null` | Use the static `edge`, `client`, and `tags` options unchanged.                      |
| a config object    | Use it **in place of** the static options.                                          |
| `{ cache: false }` | Emit no edge headers or tags, **and** send `Cache-Control: no-store` to the client. |

Returning nothing is what makes this design work. The static options stay the
enforced default for the route and the function handles only exceptions, so a
gap in the function degrades to the configured behavior rather than to no
caching at all.

`{ cache: false }` is a positive statement that a response must not be stored
anywhere, so it disables client caching too. The exception is
`client.mode: "preserve"`, which leaves the upstream `Cache-Control` alone and
suppresses only the edge headers. To skip the edge while clients keep caching
normally, use `preserve`, or return a config with a `client` policy and no
`edge`.

:::caution{title="A returned config replaces the static options wholesale"}

There is no field-by-field merge. With the configuration above, a function that
returns `{ edge: { maxAge: 60 } }` sends a 60 second edge TTL and nothing else:
**no** `staleIfError`, **no** client `max-age`, and **no** `catalog` tag.
Restate every directive the response needs, every time the function returns a
config.

:::

## Derive purge tags from the request

Per-entity tags are the main thing static configuration cannot express, and they
turn purging from a blunt instrument into a precise one. A tag of `cities`
forces a city sync to purge every city. A tag of `city-42` purges one.

```ts title="modules/cdn-cache.ts"
import type {
  CdnCacheControlConfig,
  ZuploContext,
  ZuploRequest,
} from "@zuplo/runtime";

/** Akamai rejects spaces, commas, colons, and brackets in a tag value. */
const TAG_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
/** Akamai allows 128 tags per object. Stay well clear of the ceiling. */
const MAX_ENTITY_TAGS = 8;
const ENTITY_PARAMS = { cityId: "city", cinemaId: "cinema" } as const;

function entityTags(url: URL, context: ZuploContext): string[] {
  const tags = new Set<string>();
  for (const [param, prefix] of Object.entries(ENTITY_PARAMS)) {
    for (const value of url.searchParams.getAll(param)) {
      if (TAG_PATTERN.test(value)) {
        tags.add(`${prefix}-${value}`);
      } else {
        context.log.warn(`Unusable ${param} left out of the purge tags`);
      }
      if (tags.size >= MAX_ENTITY_TAGS) {
        return [...tags];
      }
    }
  }
  return [...tags];
}

export default function cdnCacheConfig(
  response: Response,
  request: ZuploRequest,
  context: ZuploContext,
): CdnCacheControlConfig | undefined {
  // Only GET is safe for a shared cache to replay.
  if (request.method !== "GET") {
    return { cache: false };
  }
  const url = new URL(request.url);
  return {
    edge: { maxAge: 1800, staleIfError: 86400 },
    client: { maxAge: 60, visibility: "public" },
    tags: ["catalog", "cities", ...entityTags(url, context)],
  };
}
```

Both guards exist because the failure mode is silent.

**The charset filter.** Query parameters carry whatever a caller sends.
`?cityId=not a city` produces the tag `city-not a city`, which Akamai rejects
and Fastly reads as three separate keys. The policy validates tags and drops a
bad one with a warning rather than failing a `200`, but filtering at the source
keeps the log clean and makes the rule explicit.

**The cap.** A caller can repeat `?cityId=` fifty times, and once the tag header
passes the CDN's size limit the overflow is dropped — on Fastly, along with
every key after it. Capping entity tags bounds that and leaves room for the
route tags you always want present.

## Keep route TTLs in one file

Past a handful of routes, TTLs scattered across separate policy instances stop
being reviewable. Put the matrix in its own module and look up the current path.

```ts title="modules/cache-routes.ts"
export interface CacheRouteConfig {
  /** Seconds the CDN may serve this response. A purge cuts the window short. */
  edgeTtl: number;
  /** Seconds a client may reuse it. Not purgeable, so keep it far shorter. */
  clientTtl: number;
  tags: string[];
}

const CACHE_ROUTES: Record<string, CacheRouteConfig> = {
  "/v1/cities": { edgeTtl: 1800, clientTtl: 300, tags: ["cities"] },
  "/v1/movies/now-showing": { edgeTtl: 900, clientTtl: 60, tags: ["movies"] },
  "/v1/cinemas": { edgeTtl: 2400, clientTtl: 300, tags: ["cinemas"] },
};

export function getCacheRouteConfig(
  pathname: string,
): CacheRouteConfig | undefined {
  // Strip a trailing slash so `/v1/cities` and `/v1/cities/` resolve the same.
  return CACHE_ROUTES[pathname.replace(/(.)\/$/, "$1")];
}
```

```ts title="modules/cdn-cache.ts"
import type {
  CdnCacheControlConfig,
  ZuploContext,
  ZuploRequest,
} from "@zuplo/runtime";
import { getCacheRouteConfig } from "./cache-routes.ts";

/** Seconds the edge may serve a stale copy while the backend is failing. */
const STALE_IF_ERROR = 24 * 60 * 60;

export default function cdnCacheConfig(
  response: Response,
  request: ZuploRequest,
  context: ZuploContext,
): CdnCacheControlConfig | undefined {
  const url = new URL(request.url);
  const route = getCacheRouteConfig(url.pathname);
  if (!route) {
    // Refuse rather than let a static default cache something nobody reviewed.
    context.log.warn(`No cache entry for ${url.pathname}.`);
    return { cache: false };
  }
  return {
    edge: { maxAge: route.edgeTtl, staleIfError: STALE_IF_ERROR },
    client: { maxAge: route.clientTtl, visibility: "public" },
    tags: route.tags,
  };
}
```

One file now holds every TTL, one diff shows every change to them, and a unit
test can assert on the matrix without a running gateway. Refusing an unlisted
path makes adding a route a deliberate act rather than an accident.

:::note

`staleIfError` is not expressible in Akamai's `Edge-Control`. On Akamai, either
set `strategy` to `s-maxage` — as the
[`config/policies.json` example above](#the-function-returns-intent-not-headers)
does — or configure stale serving in Property Manager. The policy rejects the
combination rather than dropping the directive silently.

:::

## Refuse to cache a suspicious response

A `200` with an empty collection is ambiguous. The city may genuinely have no
cinemas, or a backend dependency may have timed out and degraded to an empty
list. Caching the second case for 30 minutes across every edge node stretches a
brief incident into a long one, and backend recovery does not clear it.

```ts title="modules/cdn-cache.ts"
import type { CdnCacheControlConfig } from "@zuplo/runtime";

export default function cdnCacheConfig(
  response: Response,
): CdnCacheControlConfig | undefined {
  // The backend sets x-result-count on every list response.
  if (response.headers.get("x-result-count") === "0") {
    return { cache: false };
  }
  // Everything else: fall back to the configured edge, client, and tags.
  return undefined;
}
```

Prefer a header over parsing the body. A header check costs a map lookup;
parsing JSON costs a full deserialization of every cacheable response on the
gateway's hottest path. Backends often already expose something usable: a result
count, a circuit breaker's `x-degraded` flag, a partial-content marker. When
none exists, adding one beats paying for the parse forever.

## Read the body only when you must

When no header will do, clone the response before reading it.

```ts title="modules/cdn-cache.ts"
import type { CdnCacheControlConfig } from "@zuplo/runtime";

export default async function cdnCacheConfig(
  response: Response,
): Promise<CdnCacheControlConfig | undefined> {
  if (!response.headers.get("content-type")?.includes("application/json")) {
    return undefined;
  }
  const payload = (await response.clone().json()) as { showtimes?: unknown[] };
  if (!Array.isArray(payload.showtimes) || payload.showtimes.length === 0) {
    return { cache: false };
  }
  return {
    edge: { maxAge: 300, staleIfError: 3600 },
    client: { maxAge: 30, visibility: "public" },
    tags: ["movies", "showtimes"],
  };
}
```

:::warning

The function receives the live response, not a copy, and the policy still has to
forward that body to the caller. Consuming it without cloning makes that
impossible, so the policy fails the request with an error naming the cause
rather than sending a truncated response.

:::

The policy does not clone for you, on purpose. `clone()` tees the stream, and
the branch nobody reads holds the whole body in memory until both branches
drain. Cloning every response would charge that cost to the majority of rules
that never look at a payload. See
[Safely clone a request or response](../programmable-api/safely-clone-a-request-or-response.mdx)
for the underlying behavior.

## Shorten the TTL as data approaches a change

When the backend knows when data next changes, the edge TTL should not outlive
it. A schedule rolling over in four minutes should not sit at the edge for
fifteen.

```ts title="modules/cdn-cache.ts"
import type { CdnCacheControlConfig } from "@zuplo/runtime";

const DEFAULT_EDGE_TTL = 900;
const MIN_EDGE_TTL = 30;
const MAX_CLIENT_TTL = 60;

export default function cdnCacheConfig(
  response: Response,
): CdnCacheControlConfig | undefined {
  // x-next-change is the ISO timestamp of the next scheduled change to this
  // data: the next showtime start, the next price rollover.
  const nextChange = response.headers.get("x-next-change");
  if (!nextChange) {
    return undefined;
  }
  const secondsToChange = Math.floor(
    (Date.parse(nextChange) - Date.now()) / 1000,
  );
  if (Number.isNaN(secondsToChange)) {
    return undefined;
  }
  const edgeMaxAge = Math.max(
    MIN_EDGE_TTL,
    Math.min(DEFAULT_EDGE_TTL, secondsToChange),
  );
  return {
    edge: { maxAge: edgeMaxAge, staleIfError: 3600 },
    client: {
      maxAge: Math.min(MAX_CLIENT_TTL, edgeMaxAge),
      visibility: "public",
    },
    tags: ["movies", "showtimes"],
  };
}
```

Both clamps earn their place. Without the ceiling, a backend reporting a change
eight hours out pins a response at the edge for eight hours. Without the floor,
a timestamp seconds away — or already past — produces a TTL so short the edge
stops absorbing anything.

The TTL is computed when the response leaves the gateway, and the policy does
not decrement `max-age` by the response's `Age`. Treat these values as a hint
that keeps the edge roughly in step with the data, not as a scheduled expiry.

## Test the function

Assert on the headers the gateway sends, not on the function's return value. The
return value is intent; the header is the contract with the CDN, and it is where
a wrong `cdn` setting or a dropped tag shows up.

| What to assert                 | Akamai                    | Fastly              | Cloudflare                     |
| ------------------------------ | ------------------------- | ------------------- | ------------------------------ |
| Edge TTL, `strategy: targeted` | `Edge-Control`            | `Surrogate-Control` | `Cloudflare-CDN-Cache-Control` |
| Edge TTL, `strategy: s-maxage` | `Cache-Control: s-maxage` | same                | same                           |
| Purge tags                     | `Edge-Cache-Tag`          | `Surrogate-Key`     | `Cache-Tag`                    |
| Client TTL                     | `Cache-Control: max-age`  | same                | same                           |

Start the dev server with `npx zuplo dev`, then run the suite against it with
`npx zuplo test --endpoint http://localhost:9000`.

```ts title="tests/cdn-cache.test.ts"
import { describe, it, TestHelper } from "@zuplo/test";
import { expect } from "chai";

describe("cdn cache headers", () => {
  it("adds a per-city purge tag", async () => {
    const url = new URL("/v1/cities?cityId=42", TestHelper.TEST_URL);
    const response = await fetch(url);
    const tags = (response.headers.get("edge-cache-tag") ?? "").split(",");
    expect(response.status).to.equal(200);
    expect(tags).to.include("cities");
    expect(tags).to.include("city-42");
  });

  it("drops an identifier the CDN charset rejects", async () => {
    const path = "/v1/cities?cityId=not%20a%20city";
    const response = await fetch(new URL(path, TestHelper.TEST_URL));
    const tags = (response.headers.get("edge-cache-tag") ?? "").split(",");
    expect(tags).to.include("cities");
    expect(tags.some((tag) => tag.startsWith("city-"))).to.equal(false);
  });
});
```

Two negative assertions are worth adding: that a `{ cache: false }` branch
really produced `no-store` with no tag header, and that no `Vary` slipped in,
since on Akamai a `Vary` suppresses caching entirely until Cache ID Modification
is configured.

The same tests run against a preview environment or production; only
`--endpoint` changes. See [Testing your API](../articles/testing.mdx) for the
full workflow. Whether the CDN honors these headers is a separate question that
lives in its configuration, so verify at the edge as well as at the gateway.

## Next steps

- [Cache at the CDN](./cdn-caching.mdx) — the static options this function
  overrides, and the `edge`/`client` split it returns.
- [Cache part of a response](./partial-response-caching.mdx) — when no TTL is
  right for the whole payload because only part of it is shared.
- [Akamai CDN caching](../dedicated/akamai/caching.mdx) — the Property Manager
  side, where you configure the stale serving `Edge-Control` cannot express.
- [CDN Cache Control policy reference](../policies/cdn-cache-control-outbound.mdx)
  — every configuration option in detail.
