---
name: fastly-fiddle
description: "Use when testing VCL against real Fastly edge infrastructure, writing assertion-based Fiddle tests, producing shareable fiddle URLs for bug reproductions, running VCL integration tests in CI, linting VCL remotely via the Fiddle API, or working with clientFetch/events/originFetches test expressions."
---

# Fastly Fiddle — Real-Edge VCL Testing

## Trigger and scope

Trigger on: Fastly Fiddle, fiddle.fastly.dev URLs, the Fiddle HTTP API, CI testing of VCL services, real-edge VCL tests, shareable VCL reproductions, `clientFetch.*`/`events.*`/`originFetches.*` test expressions, `.test.js` / Mocha specs that target fiddles, SSE `updateResult` / `waitingForSync` events, remote VCL linting via Fiddle, or validating real Fastly behavior (geo, WAF, ESI, clustering, shielding) that local tools cannot simulate.

Do NOT use for: local VCL unit testing (use `falco`), fast TDD loops (Fiddle has a 10-20s edge-sync floor per publish), Fastly Compute/Wasm testing (use `viceroy` or `fastlike`), production service deployment (use `fastly-cli`), or anything requiring authenticated Fastly API access — Fiddle does not use your Fastly API key.

Fastly Fiddle is a web-based sandbox at <https://fiddle.fastly.dev> that compiles and runs VCL on real Fastly edge nodes. Because it uses the production VCL compiler and real POPs, it's the only way outside a real service to test VCL features that depend on edge infrastructure — geolocation data, WAF, ESI, clustering, shielding, rate limiting, real TLS, and real cache behavior.

**Official UI**: <https://fiddle.fastly.dev>
**Demo CI runner**: <https://github.com/fastly/demo-fiddle-ci>
**API base**: `https://fiddle.fastly.dev` (undocumented but stable; no auth required for public fiddles)

## When Fiddle, when Falco

| Need                                                  | Use                                |
| ----------------------------------------------------- | ---------------------------------- |
| Fast local iteration (< 1s), watch mode, offline      | `falco test`                       |
| Real Fastly VCL compiler and semantics                | Fiddle                             |
| Real `client.geo.*`, WAF, ESI, rate limiting, shield  | Fiddle                             |
| Shareable URL for bug repros and support tickets      | Fiddle                             |
| CI against real edge nodes                            | Fiddle                             |
| Structured lint with line/col (no execution required) | Either                             |
| Fastly Compute (WASM)                                 | Neither — use `viceroy`/`fastlike` |

Common workflow: iterate locally with `falco test` for speed, then push edge cases to Fiddle when you need real Fastly behavior or a shareable link. See [falco-vs-fiddle.md](references/falco-vs-fiddle.md) for full trade-offs.

## Workflow: deliverable first, then the cheapest check that answers your question

Two rules keep you out of the slow path, which is what wastes time and gets
agents killed by a wall-clock limit:

1. **If your job is to produce a spec file, write it to disk first**, before
   any network call. The file is the deliverable — it must exist even if a
   later publish stalls on a cold edge-sync. Don't build the spec only inside
   a `curl --data` argument and lose it when the call blocks.

2. **Match the check to the question.** "Does this VCL compile / is the spec
   shape accepted?" is answered by a _single_ `POST /fiddle` reading `valid` —
   ~1-3s, no execution, no edge-sync wait (see gotcha #5). You only need the
   full publish → execute → SSE round trip when you must observe _runtime_
   assertion results (actual `clientFetch`/`originFetches`/`events` values),
   and that pays the 10-120s edge-sync floor per publish. **Executing is the
   exception, not the default** — reach for it deliberately, and always bound
   your wait; never block indefinitely on the stream.

### Lint-only (the common case): compile check, no execution

```bash
scripts/run-fiddle.sh --lint-only fiddle.json
# {fiddle_id, url, valid, lintStatus}. Exit 0 = compiles, 2 = lint error
# (details on stderr). No /execute, no SSE, no edge-sync wait.
```

The raw equivalent is one call — POST and read `valid`:

```bash
UA='fiddle-skill-example/1.0'
curl -sS --max-time 30 -X POST https://fiddle.fastly.dev/fiddle \
  -H 'Content-Type: application/json' -H "User-Agent: $UA" \
  --data @fiddle.json | jq '{valid, lintStatus}'   # valid==true ⇒ it compiles
```

### Full round trip (only when you need runtime results)

When you genuinely need to see assertions pass/fail on the edge, the bundled
helper handles publish → execute → SSE → completion-detection in one call,
with a bounded `--max-wait` (default 180s per attempt) so it can't hang:

```bash
scripts/run-fiddle.sh examples/robots.json
# Prints fiddle URL, then pass/fail JSON per assertion (including body_preview
# and status by default). Exits non-zero on failure. Pass --no-bodies for
# compact output.

# Iterate against an already-published fiddle without paying edge-sync again:
scripts/run-fiddle.sh --id <fiddle-id>                 # re-execute (warmest, ~2s)
scripts/run-fiddle.sh --id <fiddle-id> spec.json       # PUT then execute
```

The equivalent raw-curl flow, for reference or when the helper isn't available:

```bash
UA='fiddle-skill-example/1.0'

# 1. Create. Capture the ID and the validity flag.
RESP=$(curl -sS -X POST https://fiddle.fastly.dev/fiddle \
  -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -H "User-Agent: $UA" \
  --data '{
    "origins": ["https://http-me.fastly.dev"],
    "vcl": {
      "init": "# Synthetic robots.txt response\n# via error restart pattern",
      "recv": "if (req.url.path == \"/robots.txt\") {\n  error 601;\n}",
      "error": "if (obj.status == 601) {\n  set obj.status = 200;\n  synthetic {\"User-agent: BadBot\"};\n  return(deliver);\n}"
    },
    "requests": [
      { "path": "/robots.txt",
        "headers": "X-Custom: value\nX-Other: second",
        "tests": ["clientFetch.status is 200", "clientFetch.bodyPreview includes \"BadBot\""] }
    ]
  }')
FID=$(echo "$RESP" | jq -r '.fiddle.id')
echo "$RESP" | jq '{valid, lintStatus}'   # bail here if valid is false

# 2. Execute. Subscribe to the SSE stream IMMEDIATELY — session IDs expire fast.
SID=$(curl -sS -X POST "https://fiddle.fastly.dev/fiddle/$FID/execute?cacheID=1" \
        -H 'Accept: application/json' -H "User-Agent: $UA" | jq -r '.sessionID')

# 3. Stream results. Emits repeated `event: waitingForSync` (~10-20s on first
#    publish) then `event: updateResult` with full pass/fail data. ALWAYS cap
#    the stream with --max-time so a slow/cold sync can't block you forever;
#    on timeout, re-execute the same $FID (warm, ~2s) rather than re-publishing.
curl -sS -N --max-time 120 -H "User-Agent: $UA" "https://fiddle.fastly.dev/results/$SID/stream"
```

Full protocol details in [api.md](references/api.md).

## Wire-format gotchas

Non-obvious behavior that will break tools round-tripping fiddles programmatically:

1. **`vcl` on input, `src` on output.** You `POST {"vcl": {"recv": "..."}}` but `GET` returns `{"src": {"recv": "..."}}`. The server renames the key on normalization. Any tool that fetches a fiddle and re-publishes it must map `src` → `vcl` (or send `src` — both work on input). **Update existing fiddles with `PUT /fiddle/:id`** — same body shape as `POST`, but partial updates are not supported; omitted subroutines are cleared.

2. **`tests` is a string on the wire.** You can send `tests: ["a", "b"]` but a subsequent `GET` returns `tests: "a\nb"`. One assertion per line. Split on `\n` when reading.

3. **`headers` is a newline-joined string, not an array.** Unlike `tests` (which accepts both), `headers` must be a string: `"headers": "User-Agent: BadBot/1.0\nX-Custom: value"`. An array will be rejected with a validation error.

4. **Request fields are auto-defaulted by the server.** A `GET` of a fiddle you just created will include fields you didn't send: `method: "GET"`, `connType: "h2"` (HTTP/2 by default — matters for tests that depend on protocol), `enableCluster: true`, `enableShield: false`, `useFreshCache: false`, `sourceIP: "client"`, `followRedirects: false`, `delay: 0`. Set them explicitly if you care.

5. **Invalid VCL still gets a fiddle ID.** `POST` returns `{valid: false, lintStatus: {...}, fiddle: {id, ...}}` for broken VCL. On a **create/update** (`POST`/`PUT`) response, `valid` is the lint result — `true` if the VCL compiles, `false` if not, with details in `lintStatus`. That's the number to trust for "does this compile?", and you get it without executing anything. Don't rely on HTTP status.

    **But `valid` means something different on a `GET`.** There it tracks execution, not compilation: it stays `false` until the fiddle has been executed at least once, then flips to `true`. A fiddle that lints perfectly cleanly still reads back as `valid: false` right after you create it. So judge compilation from the create/update response (or by re-submitting the spec) — never from a `GET`. A `GET` can't distinguish a valid-but-not-yet-run fiddle from a genuinely broken one: both come back `valid: false` with an empty `lintStatus`.

6. **VCL string concat with `+` rejects parenthesized operands.** `set X = "used=" + (a - b);` fails with a _misleading_ "Remove the trailing `+` operator" suggestion — the `+` is fine, the `(` is what the parser rejects. Compute the sub-expression into a local variable first. See [spec-shape.md](references/spec-shape.md#vcl-string-concatenation).

7. **`error 8NN;` / `error 9NN;` is rejected by Fiddle lint — use 6xx.** Any 800–999 code fails with "8xx and 9xx error codes are used internally by Fastly. Use 6xx instead.", in any subroutine and any context (bare or inside `if`/`else`/`switch`). So the classic `error 801 <url>;` redirect idiom trips Fiddle lint even inside an `if` — use `error 602 "<url>";` and build the 301 in `vcl_error`. Codes 400–799 are accepted. See [spec-shape.md](references/spec-shape.md#error-codes-use-6xx-for-synthetics).

8. **Some test expressions have built-in delays.** `originFetches.count() is 0` returns `asyncDelay: 2500` — the server waits 2.5s before evaluating "did nothing happen?". Client wait time must accommodate this; 45-60s is a safe ceiling.

9. **SSE session IDs are short-lived.** Subscribe to `/results/<sessionID>/stream` within seconds of receiving the ID from `/execute`. Delayed connections get a 404. Always have the stream open before you start waiting on results.

10. **Test DSL has unusual syntax.** No `.first()`, no `reqHeaderValue()`, no `isnt`/`empty` operators. Event objects expose only `url`/`method`/`return`/`status`/`ttl` — not arbitrary `req.http.*` values. **Read [test-dsl.md](references/test-dsl.md) before writing assertions.**

11. **The server assigns a fresh fiddle ID on every `POST /fiddle`** — even for byte-identical input. IDs are not a content hash; back-to-back POSTs of the same body return different IDs, and each new ID needs its own edge-sync pass before `/execute` can produce results. **`PUT /fiddle/<id>`** keeps the URL stable (good for shared bug-repro links) but the new VCL still recompiles and propagates, so PUTs pay the same edge-sync cost as POSTs. The genuinely warm path is **re-executing an unchanged fiddle ID** — same content, repeat `/execute` calls finish in ~2s. Capture the ID from the first POST and reuse it; vary `cacheID` to force cold caches without re-publishing. (PUT is not partial — omitted subroutines are cleared, see #1.)

12. **`originFetches.count() is N` is fragile under retries and shared `cacheID`.** A retry — automatic in `run-fiddle.sh`, or manual via `--id <fid>` — re-executes against the same `cacheID`, so any origin response cached on the previous attempt is now a HIT and `originFetches.count()` drops to 0. Two reliable fixes: set **`useFreshCache: true`** on the request (forces a fresh cache, ignoring the session `cacheID` — see [spec-shape.md `Request objects`](references/spec-shape.md#request-objects)), or assert via **`events.where(fnName=fetch).count()`** (counts subroutine entries, not network calls). The same goes for `originFetches[0].*` assertions whenever the test runs after a possible warmup.

## Authoring conventions

Fiddles are read by humans in a browser. These aren't surprises, but they make shared fiddles useful:

- **Set a `title`.** Makes fiddles findable in browser tabs, bookmarks, and shared links. Example: `"title": "fastly_info.state: compound values deep dive"`.
- **Use `init` as a header comment.** The `init` subroutine renders first in the UI. Put a short summary there (~55 chars/line): `"init": "# fastly_info.state: compound values\n# Demonstrates MISS-CLUSTER, HIT-CLUSTER, HIT-SYNTH"`.
- **Format VCL with `\n` and indentation** rather than cramming everything onto one line.
- **Send `User-Agent: <tool>/<version>` on every API call.** Fiddle is unauthenticated shared infra; default `curl/x.y` or library UAs are bad citizenship. This is the API call's UA, not the simulated request's `headers`.

## Testing in CI

Reference implementation: [fastly/demo-fiddle-ci](https://github.com/fastly/demo-fiddle-ci) — a Node + Mocha harness. Clone it and write your `{spec, scenarios[]}`.

The one non-obvious thing: it publishes the fiddle once, then re-executes the same fiddle ID per scenario with different `requests[]`. Only re-execution is warm (~2s); a fresh publish pays the 10-20s edge-sync floor (see "Limits" below and gotcha #11). Keep scenarios sequential.

## References

| Topic               | File                                                | Use when...                                                        |
| ------------------- | --------------------------------------------------- | ------------------------------------------------------------------ |
| **Helper script**   | [scripts/run-fiddle.sh](scripts/run-fiddle.sh)      | Publishing + executing + streaming a fiddle in one shell command   |
| **Example payload** | [examples/robots.json](examples/robots.json)        | Starting from a known-good minimal fiddle spec                     |
| **HTTP API**        | [api.md](references/api.md)                         | Calling Fiddle endpoints directly, driving it from any language    |
| Fiddle spec shape   | [spec-shape.md](references/spec-shape.md)           | Building the JSON payload: origins, src, requests, defaults        |
| Test DSL            | [test-dsl.md](references/test-dsl.md)               | Writing `clientFetch.*`, `events.where(...)`, `originFetches.*`    |
| Falco vs Fiddle     | [falco-vs-fiddle.md](references/falco-vs-fiddle.md) | Choosing the right tool, or combining them in one workflow         |

## Limits and cautions

- **No auth required, no quota documented.** Be a good citizen: don't hammer the API in tight loops, and always send a descriptive `User-Agent` on API calls (see Authoring conventions). Use `cacheID` consistently across requests that need to share cache, and vary it to force cold caches.
- **Edge-sync floor is ~10-20s per publish, sometimes much longer.** Applies per fiddle ID, not per unique content — and every `POST /fiddle` mints a new ID (see Wire-format gotchas #11), so re-publishing identical input still pays the full sync cost. Cold publishes regularly take 60-120s in practice. Unusable for TDD. Batch changes; execute once per meaningful delta; reuse IDs via `PUT` when iterating.
- **Execution hops through a real POP** (tests observed running from IAD on node `kiad7000140`). Geographic assertions reflect wherever the fiddle executor landed.
- **Fiddles are public by default.** Don't put secrets in VCL you publish.
- **The API is undocumented.** Field names and behavior can change.
