DEV Community

Finley Zhu
Finley Zhu

Posted on

Workshop: Pin an Independent Acceptance Card for AI-Written API Clients in 90 Minutes

AI-written API clients often ship with green tests the same model drafted, which makes those tests a weak merge gate. The stronger control is an independent acceptance card: frozen fixtures, status contracts, and a latency floor the model does not author. This ninety-minute workshop gives instructors a timed outline, three exercises, and a worked example students can rerun locally or on a shared runner. The room should treat the card as the only merge signal that counts, even when the generated client looks tidy.

Why generated tests are a circular gate

Most coding assistants will happily emit a client module and a pytest file that asserts the client's own happy path. Those assertions usually replay the same JSON the prompt already described, so they rarely catch missing headers, wrong status mapping, or silent retries. Instructors see this pattern in lab submissions: the demo works on one laptop and fails when another student hits a slower shared host. An independent card flips the order of work, because the contract exists before any model output is allowed to land.

Community threads this week keep returning to a related gap: the tests used to score model output no longer bound the behavior teams actually ship. This workshop stays smaller than those essays and isolates one failure mode, a generated HTTP wrapper that looks complete while violating a tiny public contract. You will not rank models here, and you will not debate productivity culture. You will freeze a card, forbid the model from editing it, and score only that card.

What belongs on the card

Keep the card small enough that a student can read it in four minutes without a lecture. Each field must be checkable by a script that never imports the generated client as a source of truth. If a check needs to call a private helper inside client.py, it is not independent and should be rewritten.

The card should include:

  • base_url pinned to a fixture process, never to a live vendor host during class
  • paths with method, required headers, and a JSON Schema-style body predicate
  • status_map that names which client exceptions must fire on 4xx and 5xx
  • latency_ms_p95 as a classroom floor against the fixture, not as a production SLO
  • forbidden_behaviors such as swallowing errors, retrying POST, or following redirects on POST

Write the card in git before anyone opens a chat window. Students may generate clients, helpers, and extra tests, but the card file stays read-only on the exercise branch.

Workshop clock (90 minutes)

Use this schedule as a hard ceiling so the debrief is not squeezed off the timetable.

  1. 0–10 min — Frame the failure. Show a green pytest file that never sent a request. Ask what evidence is missing.
  2. 10–25 min — Exercise 1: freeze the card. Pairs write acceptance_card.json from a one-page API note. No client code yet.
  3. 25–50 min — Exercise 2: generate against a locked card. Students produce a client. The card is instructor-owned and not an editable prompt field.
  4. 50–75 min — Exercise 3: run the card twice. Once on the laptop, once on a shared runner. Diff the two receipts.
  5. 75–90 min — Debrief. Collect the three most common card failures and mark which ones are merge blockers.

If a pair finishes early, they add one forbidden behavior rather than polishing the client. Polishing the client is how rooms accidentally reintroduce circular tests.

Exercise 1 — Freeze the card before the client exists

Give each pair a short API note, not an OpenAPI dump. The note should fit on one slide so students cannot hide behind generated spec noise. Instructors should walk the room once, then stay quiet while pairs argue about status mapping.

Example note for the room:

  • POST /v1/widgets creates a widget and must send Idempotency-Key
  • Success body is { "id": string, "ok": true }
  • 400 must raise ClientError; 500 must raise ServerError
  • Fixture p95 must stay under 250 ms on the classroom runner
  • POST must not retry and must not follow redirects

Pairs then fill a JSON card. Reject cards that encode library names, retry counts inside the client, or prose such as "looks reasonable." A card is a predicate, not a design document, and predicates have to fail closed.

Exercise 2 — Generate the client with the card locked

Students may use any coding assistant they already have, including a key-free model if the lab does not issue paid credentials. The assistant can write client.py, but acceptance_card.json and test_acceptance.py remain instructor-owned files. If a student pastes the card into the prompt, they still cannot commit a card change on the exercise branch.

A useful prompt constraint for the room is boring and explicit. Tell the model the public function names and nothing else about scoring.

Write client.py only.
Public API: WidgetClient.create_widget(payload: dict, idempotency_key: str) -> dict
Raise ClientError on HTTP 4xx. Raise ServerError on HTTP 5xx.
Do not add retries. Do not follow redirects on POST.
Do not write tests. Do not modify acceptance_card.json.
Enter fullscreen mode Exit fullscreen mode

Collect the generated files without reading them first. The point of this block is to separate authoring from scoring, not to workshop prompt style.

Exercise 3 — Two receipts, one card

Run the same instructor tests against the fixture on the laptop and on one shared process. The shared run exists to catch environment stories that a single laptop hides, such as DNS delay, proxy injection, or a client that binds to localhost only. Students compare receipts, not vibes, and they record pass or fail per card field.

Suggested receipt fields:

  • card_sha256 so later debate cannot swap the contract
  • fixture_pid or container id for the shared process
  • per-path status, schema_ok, header_ok, p95_ms
  • forbidden_hit as a boolean list, not a paragraph

If local pass and remote fail disagree, the pair debugs the receipt before they debug the client. That order keeps the room from rewriting tests until the green bar returns.

Worked example students can rerun

The following files are a workshop fixture, not a production service. They are labeled examples so a room can rerun them without claiming a live vendor result. Create a directory widget_lab/ and keep the card outside the generated module.

acceptance_card.json

{
  "base_url": "http://127.0.0.1:8765",
  "latency_ms_p95": 250,
  "paths": [
    {
      "id": "create_widget",
      "method": "POST",
      "path": "/v1/widgets",
      "required_headers": ["Idempotency-Key", "Content-Type"],
      "ok_status": 201,
      "body_required_keys": ["id", "ok"],
      "body_ok_type": "boolean"
    }
  ],
  "status_map": {
    "400": "ClientError",
    "500": "ServerError"
  },
  "forbidden_behaviors": ["retry_post", "follow_redirect_on_post"]
}
Enter fullscreen mode Exit fullscreen mode

fixture_server.py

This fixture is intentionally slow by 40 ms so latency is visible, and it refuses POST retries by counting Idempotency-Key values.

#!/usr/bin/env python3
"""Workshop fixture. Example only; not a production API."""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import time

SEEN_KEYS = {}

class Handler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        return

    def _json(self, code, payload):
        raw = json.dumps(payload).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(raw)))
        self.end_headers()
        self.wfile.write(raw)

    def do_POST(self):
        time.sleep(0.04)
        if self.path != "/v1/widgets":
            return self._json(404, {"ok": False})
        key = self.headers.get("Idempotency-Key")
        ctype = self.headers.get("Content-Type", "")
        if not key or "application/json" not in ctype:
            return self._json(400, {"ok": False, "error": "missing_headers"})
        SEEN_KEYS[key] = SEEN_KEYS.get(key, 0) + 1
        if SEEN_KEYS[key] > 1:
            return self._json(400, {"ok": False, "error": "retry_detected"})
        length = int(self.headers.get("Content-Length", "0"))
        body = json.loads(self.rfile.read(length) or b"{}")
        if "name" not in body:
            return self._json(400, {"ok": False, "error": "missing_name"})
        return self._json(201, {"id": "wgt_123", "ok": True})

if __name__ == "__main__":
    ThreadingHTTPServer(("127.0.0.1", 8765), Handler).serve_forever()
Enter fullscreen mode Exit fullscreen mode

test_acceptance.py

These tests talk to the fixture through the student client, then assert card fields. They do not import expected JSON from the client module.

"""Instructor-owned acceptance tests. Example lab code; rerun locally."""
import json
import statistics
import time
from pathlib import Path

import pytest

from client import ClientError, ServerError, WidgetClient

CARD = json.loads(Path("acceptance_card.json").read_text())

@pytest.fixture(scope="module")
def client():
    return WidgetClient(CARD["base_url"])

def test_create_widget_matches_card(client):
    path = CARD["paths"][0]
    samples = []
    last = None
    for i in range(8):
        t0 = time.perf_counter()
        last = client.create_widget({"name": "demo"}, idempotency_key=f"k-{i}")
        samples.append((time.perf_counter() - t0) * 1000)
    p95 = sorted(samples)[int(0.95 * (len(samples) - 1))]
    assert last["ok"] is True
    assert isinstance(last["id"], str) and last["id"]
    assert p95 <= CARD["latency_ms_p95"], p95

def test_400_maps_to_client_error(client):
    with pytest.raises(ClientError):
        client.create_widget({}, idempotency_key="bad-body")

def test_post_does_not_retry(client, monkeypatch):
    calls = {"n": 0}
    real = client._transport

    def wrapped(*args, **kwargs):
        calls["n"] += 1
        return real(*args, **kwargs)

    monkeypatch.setattr(client, "_transport", wrapped)
    client.create_widget({"name": "once"}, idempotency_key="no-retry")
    assert calls["n"] == 1
Enter fullscreen mode Exit fullscreen mode

Reference client shape (example, not a scored solution)

Students replace this file. The _transport hook exists so the no-retry test can observe calls without scraping logs.

"""Example student-shaped client. Not a scored solution."""
import json
from urllib.error import HTTPError
from urllib.request import Request, urlopen

class ClientError(Exception):
    pass

class ServerError(Exception):
    pass

class WidgetClient:
    def __init__(self, base_url):
        self.base_url = base_url.rstrip("/")

    def _transport(self, req):
        return urlopen(req, timeout=3)

    def create_widget(self, payload, idempotency_key):
        data = json.dumps(payload).encode("utf-8")
        req = Request(
            self.base_url + "/v1/widgets",
            data=data,
            method="POST",
            headers={
                "Content-Type": "application/json",
                "Idempotency-Key": idempotency_key,
            },
        )
        try:
            with self._transport(req) as resp:
                return json.loads(resp.read().decode("utf-8"))
        except HTTPError as exc:
            if 400 <= exc.code < 500:
                raise ClientError(str(exc.code)) from exc
            if exc.code >= 500:
                raise ServerError(str(exc.code)) from exc
            raise
Enter fullscreen mode Exit fullscreen mode

Rerun commands for the room:

python fixture_server.py &
python -m pytest test_acceptance.py -q
Enter fullscreen mode Exit fullscreen mode

A pair has finished Exercise 3 when they can paste two receipts with the same card_sha256 and explain every mismatch in one sentence each. If they cannot explain a mismatch, they do not get to change the client yet.

Decision table for merge blockers

Card field Local fail Shared-runner fail Merge rule
Missing Idempotency-Key Client omitted header Proxy stripped header Block either way; fix client first, then re-run remote
Body missing ok Model invented a different JSON Fixture and laptop disagree Block; schema is the card, not the model
p95 above 250 ms Laptop is overloaded Shared host is noisy Warn once, block on second consecutive remote fail
POST retried Client-level retry loop Library default retry Block; forbidden behavior is not a performance knob
500 mapped to ClientError Status map ignored Partial deploy of student file Block; exception types are part of the card

The table is the debrief artifact. Instructors should fill the last column with the room, then freeze it for the rest of the term so later labs do not renegotiate merge rules mid-demo.

Where a free model and a free server actually help

The method does not require a particular vendor. It does require two lab constraints that many classrooms lack: a model students can call without purchasing keys, and one shared process that every laptop can hit. MonkeyCode is an open-source coding assistant with free model access and a free server option, which is enough to host the fixture and to generate client.py during Exercise 2. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Use the free server as the remote receipt target, not as a claim about production capacity. Keep the fixture tiny, pin the card in git, and treat any extra product UI as out of scope for the ninety minutes. If the room already has a campus runner and campus model access, skip the product entirely and keep the card.

Limitations

This card does not prove the client is correct against a live vendor, because the fixture is a classroom double with a fixed 40 ms pause. It also does not replace contract testing tools that already generate consumer pacts, schema registries, or recorded traffic from staging. Shared free hosts add noise, so a single p95 miss is a warning rather than a diagnosis of the generated code.

Students can still cheat by editing the card if the branch is not protected, and a model can still write a client that passes the card while leaking secrets in headers. Eight latency samples are a teaching device, not a statistically serious percentile. Label those limits on the slide so nobody files the receipt as a benchmark.

Who should skip this workshop

Skip this outline if the team already merges on Pact, OpenAPI-generated servers, or a staging replay harness that authors did not generate in the same session. Skip it if the goal is a model leaderboard, a token-cost study, or an incident review for a production outage. Skip it for junior students who have not yet issued an HTTP request without a tutorial copy-paste, because the card will hide the networking basics they still need.

The useful close for instructors is operational, not promotional: lock the card, generate the client, and keep the only scored artifact outside the model's edit window. If a lab still needs a key-free model and one shared fixture host, the free model access and free server option above are sufficient to rerun this example with the room.

Top comments (0)