My side project demo died at 8:14 pm on a Saturday. The model endpoint answered with a 429. The browser showed a spinner and stayed there.
I had no counter. I had no fallback. I did not know how many calls were left.
So I cut the weekend scope down to one feature. A guard that refuses calls before the provider does.
This log covers what I kept, what I deleted, and the test that made the demo boring.
The scope cut came first
Three hours on Saturday morning. I wrote the cut list before any code.
| Kept | Cut | Reason |
|---|---|---|
| One model-call wrapper | Per-user API keys | One demo user is enough |
| SQLite counter | Redis | A file survives a restart |
| Three-rung fallback | Retry with backoff | Retries spend the same budget |
| Preflight script | Usage dashboard | I need one number, not charts |
Two rules fell out of that table. Every model call goes through one function. Every budget decision lands in a file you can read.
Both rules are boring. That is the point.
Where it ran
I built and tested this against MonkeyCode's free model access. The demo endpoint runs on the same project's free server option.
Check the current terms yourself for model names, quota, and limits. Availability changes, and my weekend is not a contract.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Step 1 — Funnel every call through one function
Create model_call.py. Make it the only file that imports a provider SDK.
# model_call.py
import time
CALLS = []
def call_model(prompt: str) -> str:
"""The only place in this repo allowed to touch a provider."""
started = time.perf_counter()
try:
return _provider_call(prompt) # swap per provider
finally:
CALLS.append((time.perf_counter() - started) * 1000)
Now verify the rule with grep.
grep -rn "openai\|anthropic\|http" --include="*.py" .
One file should match. Two matches means your budget is already a lie.
Step 2 — Put the counter in SQLite, not in memory
A dict resets on every restart. Your provider's quota does not.
# budget.py
import os, sqlite3, time
DB = os.environ.get("BUDGET_DB", "budget.sqlite")
SCHEMA = """
CREATE TABLE IF NOT EXISTS usage (
day TEXT NOT NULL,
route TEXT NOT NULL,
calls INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (day, route)
);
"""
def _conn():
db = sqlite3.connect(DB, isolation_level=None, timeout=5)
db.executescript(SCHEMA)
return db
def try_reserve(route: str, cap: int) -> bool:
day = time.strftime("%Y-%m-%d")
with _conn() as db:
db.execute("BEGIN IMMEDIATE")
row = db.execute(
"SELECT calls FROM usage WHERE day=? AND route=?", (day, route)
).fetchone()
used = row[0] if row else 0
if used >= cap:
return False
db.execute(
"INSERT INTO usage (day, route, calls) VALUES (?,?,1) "
"ON CONFLICT(day, route) DO UPDATE SET calls = calls + 1",
(day, route),
)
return True
BEGIN IMMEDIATE takes the write lock up front. Two threads cannot read the same count and both increment it.
Inspect the ledger any time.
sqlite3 budget.sqlite "select * from usage;"
One row per day per route. No dashboard needed.
Step 3 — Build a three-rung ladder
A hard stop is better than a crash. A graceful stop is better than both.
| Rung | Trigger | What the user sees | Calls spent |
|---|---|---|---|
| fresh | count < cap | real answer | 1 |
| cached | count == cap, prompt seen | stored answer plus its date | 0 |
| template | count == cap, no cache | "budget spent, try tomorrow" | 0 |
# ask.py
from budget import try_reserve
from model_call import call_model
from cache import cache_lookup, cache_store
MODEL_CAP = 50
TEMPLATE = "Daily demo budget is spent. The cached answers below are still real."
def ask(prompt: str) -> tuple[str, str]:
if try_reserve("model", MODEL_CAP):
text = call_model(prompt)
cache_store(prompt, text)
return text, "fresh"
hit = cache_lookup(prompt)
if hit:
return hit, "cached"
return TEMPLATE, "template"
Return the rung, not just the text. Your UI needs to say which one it got.
Show the rung in the corner of the screen. Users forgive a limit they can see.
Step 4 — Write the failure test before the demo
This test is the whole reason the demo worked.
# test_budget.py
import importlib
def test_ladder_degrades(tmp_path, monkeypatch):
monkeypatch.setenv("BUDGET_DB", str(tmp_path / "b.sqlite"))
import budget, ask
importlib.reload(budget)
importlib.reload(ask)
ask.MODEL_CAP = 2
rungs = [ask.ask("ping")[1] for _ in range(5)]
assert rungs[:2] == ["fresh", "fresh"]
assert "fresh" not in rungs[2:]
Run it.
pytest -q test_budget.py
If the third call returns fresh, your cap is not wired in. Fix that before you touch the UI.
Step 5 — Ship a preflight you can run in ten seconds
Cold servers and cold tabs are different animals. Measure the one you will demo.
# preflight.py
import sys, time, statistics, urllib.request
base, n = sys.argv[1], int(sys.argv[2])
def timed(url):
t0 = time.perf_counter()
try:
with urllib.request.urlopen(url, timeout=10) as r:
r.read()
return r.status, (time.perf_counter() - t0) * 1000
except Exception as e:
return getattr(e, "code", 0), (time.perf_counter() - t0) * 1000
rows = [timed(base + "/health") for _ in range(n)]
codes = {c for c, _ in rows}
lat = sorted(ms for _, ms in rows)
p95 = lat[max(0, int(len(lat) * 0.95) - 1)]
print(f"codes={codes} p50={statistics.median(lat):.0f}ms p95={p95:.0f}ms")
print("PASS" if codes == {200} and p95 < 800 else "FAIL")
Run it before every demo.
python preflight.py https://your-demo-host 20
My acceptance criteria were simple. Zero non-200s. p95 under 800 ms. At least one visibly degraded answer when I forced the cap to zero.
I did not record a benchmark table. The numbers only matter for your host on your network.
What the demo looked like
Three runs from a cold browser tab. First run warmed the server. Second run showed the fresh path. Third run had the cap forced to zero.
The third run is the one I care about. It printed a template answer and a cached answer, both with timestamps.
Nobody clapped. Nobody saw a spinner either.
What I skipped
- Token counting. I count calls, not tokens. Cheaper to build, less accurate.
- Per-user quotas. One demo user. Multi-user needs a real key store.
- Retries. A retry spends the same budget with worse odds.
- Caching by embedding. Exact-prompt cache only. Fuzzy matching is a different weekend.
- Metrics export. A SQLite file is a metrics system if you can read SQL.
Limits, and who should skip this
SQLite locks are fine for one process. Run four workers and you will want Postgres.
Call counts are a proxy. Long prompts cost more, and this guard cannot see that.
Skip this approach if you bill customers, if you need per-tenant quotas, or if your provider changes limits without notice.
Also skip it if a hard cap would break the product. This design says no. Sometimes that is wrong.
The takeaway
Cut until one feature is left. Wrap it, count it, degrade it, test it.
The guard took about three hours. The demo took ten minutes. The 429 took four seconds and taught me more.
If you want to try the same build, MonkeyCode's free model access and free server option are enough to run it end to end. Just read their current limits first.
Top comments (0)