You unzip the take-home. The README is calm. POST /holds returns 201 and a JSON body a product manager could have sketched on a napkin. Pytest is green on the first run.
Then you do the rude thing. You send the same body twice with the same Idempotency-Key. Two rows appear. Two hold_id values. The candidate wrote a happy path. They did not write a contract.
That gap is not a style nit. It is the incident you page for when a phone double-taps or a webhook retries. Cheap generation made the first 201 cheaper. It did not make replay any rarer. Agents still assume the network is a conversation. Production treats it as a ledger.
This packet is an interview you can give a human or a coding agent. You are not hunting for “AI smell.” You are scoring what happens when the spec is loud about the first request and quiet about the second.
A spare box is a fair interview room
Small teams already let a model draft HTTP handlers. Some of them park that model on a machine they do not mind wiping. If you use MonkeyCode, that pairing exists without standing up a GPU yourself: free model access, plus a free server option to run the packet. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
This article will not quote token ceilings, model names, or hardware SKUs it cannot verify. Treat the product as one disposable place to run the exercise. Steal the rubric even if you never log in. You remain the interviewer. The model remains the candidate. The prompt stays slightly unfair in the same way production is unfair.
The prompt you hand over
Give the candidate this and nothing else. Do not add “make it idempotent” in chat. The silence is the test.
# Take-home: room hold API (90 minutes)
Build a tiny HTTP service in Python 3.
POST /holds
JSON body: {"room_id": string, "guest_email": string, "nights": int}
Success: 201
Body: {"hold_id": string, "room_id": string, "nights": int, "status": "held"}
Clients may send an Idempotency-Key header. The written spec for that
header is: "follow common payment-API practice." Do not invent a second
service. Persist on disk so a process restart does not forget holds.
Deliver:
- the server
- tests you would actually run
- ASSUMPTIONS.md listing every behavior you inferred
Out of scope: auth, payments, a frontend, Kubernetes, Redis, Docker
Compose unless you can justify the dependency in ASSUMPTIONS.md in one
paragraph.
You did not name a processor. You did not say “return the first body on replay.” You did not forbid an in-memory dict. A careful candidate asks, or writes the assumption down. A fluent candidate ships a second insert and a smile.
What good looks like
A passing solution does four boring things. It stores the first response. It replays that response when the key and body match. It rejects a reused key with a different body. It survives a restart.
The sample below is a proposed take-home solution, not booking software. SQLite is the store because the prompt said disk and did not say Redis. Run it locally before you trust it.
# sample_solution/app.py — proposed packet solution, not production
import hashlib
import json
import sqlite3
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
DB = "holds.db"
def db():
conn = sqlite3.connect(DB)
conn.execute(
"""CREATE TABLE IF NOT EXISTS holds (
hold_id TEXT PRIMARY KEY,
room_id TEXT NOT NULL,
guest_email TEXT NOT NULL,
nights INTEGER NOT NULL,
status TEXT NOT NULL
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS idempotency (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status INTEGER NOT NULL,
body TEXT NOT NULL
)"""
)
conn.commit()
return conn
def request_hash(body: bytes) -> str:
return hashlib.sha256(body).hexdigest()
class Handler(BaseHTTPRequestHandler):
def _json(self, code, payload):
raw = json.dumps(payload).encode()
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):
if self.path != "/holds":
return self._json(404, {"error": "not_found"})
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length)
key = self.headers.get("Idempotency-Key")
try:
data = json.loads(raw.decode())
room_id = data["room_id"]
email = data["guest_email"]
nights = int(data["nights"])
if nights < 1:
raise ValueError("nights")
except (KeyError, ValueError, json.JSONDecodeError):
return self._json(400, {"error": "invalid_body"})
conn = db()
try:
if key:
row = conn.execute(
"SELECT request_hash, status, body FROM idempotency WHERE key = ?",
(key,),
).fetchone()
if row:
if row[0] != request_hash(raw):
return self._json(409, {"error": "idempotency_conflict"})
return self._json(row[1], json.loads(row[2]))
hold_id = str(uuid.uuid4())
payload = {
"hold_id": hold_id,
"room_id": room_id,
"nights": nights,
"status": "held",
}
conn.execute(
"INSERT INTO holds VALUES (?, ?, ?, ?, ?)",
(hold_id, room_id, email, nights, "held"),
)
body = json.dumps(payload)
if key:
conn.execute(
"INSERT INTO idempotency VALUES (?, ?, ?, ?)",
(key, request_hash(raw), 201, body),
)
conn.commit()
return self._json(201, payload)
finally:
conn.close()
if __name__ == "__main__":
db()
ThreadingHTTPServer(("127.0.0.1", 8080), Handler).serve_forever()
The tests that matter hit the socket. They do not congratulate a mock for being called.
# sample_solution/test_holds.py — proposed tests; run against a live server
import json
import urllib.error
import urllib.request
import uuid
def post(body, key=None):
headers = {"Content-Type": "application/json"}
if key:
headers["Idempotency-Key"] = key
req = urllib.request.Request(
"http://127.0.0.1:8080/holds",
data=json.dumps(body).encode(),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read())
def test_replay_reuses_hold_id():
key = str(uuid.uuid4())
body = {"room_id": "12A", "guest_email": "a@b.co", "nights": 2}
s1, p1 = post(body, key)
s2, p2 = post(body, key)
assert s1 == 201 and s2 == 201
assert p1["hold_id"] == p2["hold_id"]
def test_same_key_different_body_conflicts():
key = str(uuid.uuid4())
post({"room_id": "1", "guest_email": "a@b.co", "nights": 1}, key)
status, _ = post({"room_id": "2", "guest_email": "a@b.co", "nights": 1}, key)
assert status == 409
In the ninety-minute window you run the boring commands, not a demo script.
python3 app.py &
python3 -m pytest test_holds.py -q
curl -s -D - -H 'Idempotency-Key: k1' -H 'Content-Type: application/json' \
-d '{"room_id":"12A","guest_email":"a@b.co","nights":2}' http://127.0.0.1:8080/holds
curl -s -D - -H 'Idempotency-Key: k1' -H 'Content-Type: application/json' \
-d '{"room_id":"12A","guest_email":"a@b.co","nights":2}' http://127.0.0.1:8080/holds
If the second curl mints a new hold_id, the packet failed. Pretty types do not save it. A restart that forgets the key fails it too. Kill the process, start it again, send k1 once more. Same body, same hold, or it is not persistence.
The rubric
Score the packet, not the prose. Four dimensions, zero to two each. Eight is a next-round on this slice. Four or below is a polite no, even if the README sings.
| Dimension | 0 | 1 | 2 |
|---|---|---|---|
| Replay | Second POST inserts again | RAM only; dies on restart | Disk-backed; same hold_id
|
| Conflict | Ignores body changes | 400/500 soup | Explicit 409 on same key, different body |
| Assumptions | Invents Redis, Stripe, or a user service | Names a choice, no reason |
ASSUMPTIONS.md with store, status codes, header semantics |
| Tests | Works on my machine | One happy-path test | Replay + conflict + a restart check |
You can run the same grid on a model. Fluency is not a dimension. If the agent writes a five-hundred-word architecture note and a dict in RAM, that is a one on replay and a zero on assumptions. Cheap completions do not buy you a ledger. They buy you a first draft of a first request.
Think of the header as a coat-check ticket. The same ticket returns the same coat. A different coat on the same ticket is a fight at the counter, not a second hanger. Candidates who skip the ticket are not “moving fast.” They are opening a second closet and hoping nobody counts.
Failure modes you will see this week
The first is the double insert. The handler trusts the network to be one-shot. Humans do this. Models do it faster, with neater docstrings.
The second is the fake platform. ASSUMPTIONS.md says the obvious store is Redis with a twenty-four hour TTL. Nothing in the prompt asked for a cache cluster. That invention wears an ops badge and still fails the “do not invent a second service” line.
The third is status-code jazz. Replay returns 200 once and 201 the next time. Clients that branch on status now fork in the wild. Pick one code, write it down, keep it. Payment-style APIs usually replay the original success as the original success, not as a newly invented “already existed” resource.
The fourth is test theater. The suite mocks the database so thoroughly that the idempotency table never exists. Green tests, red production. When you score a model, watch for asserts that the mock was called rather than that the second HTTP body matched the first.
The fifth is swallowing poison. Invalid nights becomes 201 with nights: 0. That is not kindness. That is a hold operations cannot price. A 400 with a boring error key is the adult move.
The sixth is key amnesia. No header means “always insert,” which is defensible if written down. No header plus a silent fingerprint of the body is a different product. Either choice can pass. An unstated hybrid cannot.
None of these require a frontier model to avoid. They require the candidate to treat the second request as part of the type. When generated code is cheap, the missing type is how technical debt arrives before lunch.
Limitations, and who should skip this
This packet does not prove exactly-once delivery across two app nodes. SQLite will not save you from a split brain. It does not grade latency, auth, PCI, or whether guest_email should have been an opaque guest id. It will not tell you which vendor model is best, because this article is not a bake-off and it ships no timings.
Do not paste real guest emails into a shared model prompt. Synthetic data only. Do not treat a passing agent as a payments engineer.
Skip this if you are hiring for whiteboard algorithms and the person will never own HTTP. Skip it if your booking path is a bought-in API and nobody on the team will see this code. Skip it if you wanted a product tour. The free model and free server matter here only as a wipeable room in which to watch an agent meet a quiet spec. The rubric still works on a laptop with nothing extra installed.
Keep the prompt short. Keep the second curl rude. The interview is not the first 201. The interview is whether that 201 comes back as the same hold, or as a second room you now have to unpick.
Top comments (0)