DEV Community

Dakota Wu
Dakota Wu

Posted on

Cap the Cassette Before an AI-Drafted Indie API Leaves the Laptop

A 200 from /health is not a ship decision. The ship decision is a cassette that names every outbound host, caps the bytes that leave the process, and still passes after the latest agent patch.

Solo founders lose weekends to pretty handlers. The model adds a translation client, an object store, and a mail vendor while the README still claims SQLite. The cassette budget is the cheap proof that none of that shipped.

AI coding tools made the demo path cheap. They also made unfinished work look finished. A generated FastAPI app can grow three paid upstreams in one turn and still return JSON that looks like a product. The founder who ships on that screenshot inherits the invoice. The founder who replays a tape with a host-and-byte cap does not.

The indie constraint

A solo founder ships today or the idea dies. Paid staging clusters do not fit that clock. The useful test on night one is not a 10k-VU soak. It is a short tape of the routes that will actually be public, replayed against a process that is forbidden to talk to anyone outside an allowlist.

That is a performance test in the only sense that matters before DNS exists. Did the handler stay cheap. Latency percentiles can wait. Host cardinality cannot. Byte caps cannot.

Keep the product on loopback until the tape is green. Raise the budget only when a real payload forces it. Never raise it because the agent asked for a “production-ready” client.

What the budget measures

Three numbers. Nothing else on the first weekend.

  1. Distinct outbound hosts observed while the tape runs.
  2. Total request bytes the client sent, headers included.
  3. Count of connects that were not loopback, localhost, or an explicit fixture host.

If any number exceeds the file, the patch does not ship. The health endpoint is ignored. The cassette is the evidence folder. A dashboard without those three numbers is decoration.

Files to add this afternoon

Keep the layout boring so an agent cannot “clean it up” into a cloud folder.

api/
  app.py
cassette/
  budget.toml
  tape.json
tools/
  cassette_budget.py
Makefile
Enter fullscreen mode Exit fullscreen mode

The API stays small. The tape stays in git. The outbound log stays local. That split is the whole point. Agents rewrite READMEs. They hesitate when a committed budget.toml fails a command the Makefile already names.

1. The budget file

# cassette/budget.toml
max_distinct_hosts = 1
max_outbound_bytes = 65536
max_non_local_requests = 0
allow_hosts = ["127.0.0.1", "localhost", "::1"]
allow_ports = [8000, 8080]
Enter fullscreen mode Exit fullscreen mode

One allowed remote host is already generous for a laptop demo. Zero non-local requests is the default for a founder who wants the bill at zero. Raise the byte cap only after a real payload requires it. Do not raise it to silence a red run.

2. The tape

Label: example cassette. Replace bodies with the product’s real shapes before trusting a green run.

{
  "base_url": "http://127.0.0.1:8000",
  "cases": [
    {
      "name": "create_note",
      "method": "POST",
      "path": "/notes",
      "headers": {"Content-Type": "application/json"},
      "body": {"title": "ship", "body": "today"},
      "expect_status": [200, 201]
    },
    {
      "name": "read_note",
      "method": "GET",
      "path": "/notes/1",
      "expect_status": [200]
    },
    {
      "name": "health",
      "method": "GET",
      "path": "/health",
      "expect_status": [200]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Do not put production URLs in examples. Agents copy examples into clients. The tape stays on loopback until the budget is green. Extra routes stay out of the tape until the founder is willing to support them in public.

3. The checker

The script starts the API in a subprocess, replays the tape, and records socket.connect plus socket.create_connection through a sitecustomize hook. It is a working sketch. Founders should read it before they trust it. It will not see every C extension. It will catch the usual Python HTTP clients an agent pastes in on a Saturday.

# tools/cassette_budget.py
"""Replay a cassette and fail if outbound traffic exceeds budget.toml.

Unexecuted on this machine as published. Treat as a working sketch.
"""
from __future__ import annotations

import json
import os
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path

try:
    import tomllib
except ImportError:  # pragma: no cover
    import tomli as tomllib  # type: ignore

ROOT = Path(__file__).resolve().parents[1]
BUDGET = tomllib.loads((ROOT / "cassette" / "budget.toml").read_text())
TAPE = json.loads((ROOT / "cassette" / "tape.json").read_text())
LOG = ROOT / "cassette" / "outbound.jsonl"
HOOK_DIR = ROOT / "cassette"


class BudgetError(RuntimeError):
    pass


HOOK_SRC = r'''
import json, os, socket
from pathlib import Path
LOG = Path(os.environ["CASSETTE_LOG"])
_real_connect = socket.socket.connect
_real_create = socket.create_connection

def _connect(self, addr, *a, **k):
    host = addr[0] if isinstance(addr, tuple) else str(addr)
    port = addr[1] if isinstance(addr, tuple) and len(addr) > 1 else None
    with LOG.open("a") as fh:
        fh.write(json.dumps({"host": str(host), "port": port}) + "\n")
    return _real_connect(self, addr, *a, **k)

def _create(address, timeout=None, source_address=None):
    host, port = address[0], address[1]
    with LOG.open("a") as fh:
        fh.write(json.dumps({"host": str(host), "port": port}) + "\n")
    return _real_create(address, timeout=timeout, source_address=source_address)

socket.socket.connect = _connect
socket.create_connection = _create
'''


def install_hook() -> None:
    (HOOK_DIR / "sitecustomize.py").write_text(HOOK_SRC)
    os.environ["CASSETTE_LOG"] = str(LOG)
    os.environ["PYTHONPATH"] = str(HOOK_DIR) + os.pathsep + os.environ.get("PYTHONPATH", "")


def start_api() -> subprocess.Popen:
    LOG.write_text("")
    env = os.environ.copy()
    proc = subprocess.Popen(
        [sys.executable, "-m", "uvicorn", "api.app:app", "--host", "127.0.0.1", "--port", "8000"],
        cwd=ROOT,
        env=env,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.PIPE,
    )
    deadline = time.time() + 8
    while time.time() < deadline:
        try:
            with socket.create_connection(("127.0.0.1", 8000), timeout=0.2):
                return proc
        except OSError:
            if proc.poll() is not None:
                err = proc.stderr.read().decode() if proc.stderr else ""
                raise BudgetError(f"api exited early: {err}") from None
            time.sleep(0.1)
    proc.kill()
    raise BudgetError("api did not bind 127.0.0.1:8000")


def replay() -> int:
    sent = 0
    base = TAPE["base_url"].rstrip("/")
    for case in TAPE["cases"]:
        url = base + case["path"]
        data = None
        headers = dict(case.get("headers") or {})
        if "body" in case:
            data = json.dumps(case["body"]).encode()
            headers.setdefault("Content-Type", "application/json")
            sent += len(data)
        req = urllib.request.Request(url, data=data, headers=headers, method=case["method"])
        sent += sum(len(k) + len(str(v)) for k, v in headers.items())
        try:
            with urllib.request.urlopen(req, timeout=5) as resp:
                status = resp.status
                sent += len(resp.read())
        except urllib.error.HTTPError as exc:
            status = exc.code
        if status not in case["expect_status"]:
            raise BudgetError(f"{case['name']} status {status}")
    return sent


def evaluate(sent_bytes: int) -> None:
    rows = []
    if LOG.exists() and LOG.read_text().strip():
        rows = [json.loads(line) for line in LOG.read_text().splitlines() if line.strip()]
    allow = set(BUDGET["allow_hosts"])
    hosts = sorted({r["host"] for r in rows})
    foreign = [r for r in rows if r["host"] not in allow]
    if len(hosts) > BUDGET["max_distinct_hosts"]:
        raise BudgetError(f"hosts {hosts} exceed max_distinct_hosts")
    if sent_bytes > BUDGET["max_outbound_bytes"]:
        raise BudgetError(f"bytes {sent_bytes} exceed cap")
    if len(foreign) > BUDGET["max_non_local_requests"]:
        raise BudgetError(f"non-local connects: {foreign}")
    print(json.dumps({"hosts": hosts, "bytes": sent_bytes, "foreign": len(foreign)}, indent=2))


def main() -> int:
    install_hook()
    proc = start_api()
    try:
        sent = replay()
        evaluate(sent)
    finally:
        proc.terminate()
        try:
            proc.wait(timeout=3)
        except subprocess.TimeoutExpired:
            proc.kill()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

The hook is coarse on purpose. Weekend APIs die from httpx.AsyncClient pointed at a tutorial host, not from a custom libc shim. Read the diff when the log is empty and the handler still smells like a vendor SDK. Empty logs are a signal to inspect, not a blessing.

Run order

Do this in order. Skipping a step is how a paid SDK lands in the lockfile after the demo.

  1. Freeze the cassette paths to the routes the founder actually wants public this week. Extra routes stay commented in the OpenAPI file.
  2. Start from a clean venv. Install only what api/app.py imports today.
  3. Run python tools/cassette_budget.py. A BudgetError means the patch is not done.
  4. If the agent added a helper module, re-run before committing. Do not batch five agent turns and then test.
  5. Keep budget.toml and tape.json in git. Leave outbound.jsonl as a local artifact.
  6. Point DNS at anything other than loopback only after step 3 is green on a second machine.

Makefile glue keeps the command short enough for a tired Sunday.

.PHONY: cassette
cassette:
    python tools/cassette_budget.py
Enter fullscreen mode Exit fullscreen mode

A founder who cannot run make cassette on a train is not ready to take the API public. The command has to fit in a small terminal. The failure has to name the host that broke the budget.

Failure modes the tape usually catches

The first red run is rarely exotic. An agent adds httpx and a base URL from a README. The hook records that host. The budget fails. The founder deletes the client and the extra settings block.

The second red run is a “just in case” object-store helper. The create-note case never needed a bucket. The tape still trips because import-time setup opened a session. Move I/O to the request path, then delete the helper if the path does not need it this week.

The third red run is a chatty list endpoint. Byte counts in this sketch include response bodies. A 2 MB welcome payload blows a 64 KB cap even when every host is local. That is intended on week one. Pagination is cheaper than a dump.

Where a free model and a free server fit

Local green is the bar. A second machine is the confirmation. After the laptop cassette passes, the same tape can be pointed at a throwaway process on a free server so the founder is not the only host that has ever run the handler.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode is relevant here only as a place with free model access for drafting extra cassette cases from an OpenAPI file, and a free server option for that second-hop replay. It does not replace the budget file. It does not prove the hook saw every socket. Treat both the free model access and the free server option as capacity that can change; keep the cassette runner in the repo so the gate still works offline.

Drafting extra cases is mechanical. Paste the path list. Require JSON cases that stay on 127.0.0.1 and never invent vendor URLs. Reject any case whose path is not already in the public set. Then re-run the checker. A generated tape that mentions a paid host is a failed draft, not a starting point.

What this does not prove

The cassette does not prove authentication is correct. It does not prove SQL injection is absent. It does not prove the handler meets a latency SLO. It does not see traffic from a sidecar the process did not spawn.

Agents can cheat the budget by writing files instead of calling HTTP, or by shelling out to curl. Add a later gate if that shows up: a denylist of subprocess binaries. Until then, read the patch. The cassette is a filter. It is not a substitute for a founder who still opens the diff.

Realistic API tests still need production-shaped payloads, think times, and failure injection. This tape is not that suite. It is the gate that keeps the suite from running against a process that already called a vendor.

Who should skip this

Skip the cassette budget if the product already has a real staging bill and a load suite. Skip it if the API handles payments, medical data, or anyone else’s secrets. Skip it if the team needs multi-region failover. Those products need contracts and reviews this workflow does not provide.

Skip it if nobody will maintain tape.json. A stale tape is worse than no tape. It blesses a demo path while the agent rewires /export to a paid encoder. A founder who will not edit the tape when a route changes should not publish the route.

Ship rule

The ship rule is one line. If make cassette is red, the API stays on the laptop. If it is green, the founder may take a second hop on a free server, then publish. The demo 200 is not evidence. The cassette is.

If the tape needs more cases than a tired evening allows, draft them against the OpenAPI file with free model access, keep every URL on loopback, and re-run the budget before anyone else hits the process.

Top comments (0)