Phase 7 gave the app a brain. Phase 8 was about making it trustworthy — the unglamorous gate between "works on my machine" and "safe to put a URL in front of."
No new features. Instead: a security audit that probed my own API for leaks, a global exception handler so unexpected errors stop leaking internals, a five-test pytest suite that turns every manual check into a permanent one, and a git-history secrets sweep. Plus the war story — a logging placeholder I fat-fingered the same way twice, a test that silently never ran, and a login that failed because of one capital letter.
Spoiler: the app had zero leaks. Almost every bug this phase was in the terminal, not the code.
/health
endpointPhase 7b ended with the seam holding: a category column, a rules engine, and a Gemini-backed LLMCategorizer
living behind the same Categorizer
contract — validated, cached, and with a fallback that was finally loud about failing.
I closed that post nagging at three things: the cache dies on restart, the model name is a drifting alias, and my free tier can't afford to be the default. All real. All enhancements.
But there was a bigger question I'd been walking past: is this thing actually safe to deploy? I had auth, I had owner-scoped queries, I believed users were isolated — but belief isn't proof. Phase 8 is where I stop believing and start verifying.
The temptation was to jump straight to "add security stuff." I didn't. You can't harden what you haven't observed, so I ordered the work from lowest risk and highest evidence outward:
| Step | What | Why this order |
|---|---|---|
| 1 | A /health endpoint |
|
| Purely additive warm-up; also the thing my host will ping in Phase 9 | ||
| 2 | Audit the real API for leaks | Get evidence before writing a single fix — don't guess what leaks |
| 3 | Global exception handler | The one gap the audit couldn't test: the unexpected 500 |
| 4–5 | A pytest suite | Turn every manual proof into a permanent, re-runnable one |
| 6 | Pagination | Put a ceiling on an unbounded list before it becomes a problem |
| 7 | Secrets / git-history sweep | The one item with real downside if skipped before going public |
The principle from Phase 7 still drives it: observe first, then change one thing at a time. When something breaks, I want to know it broke in the thing I just touched.
/health
endpoint
Every real route in my app is auth-gated behind get_current_user
. That's correct for expenses — but a monitoring system can't log in. So I need exactly one deliberately public, side-effect-free route that answers "are you alive?" with a fast, boring 200
.
@app.get("/health")
def health_check():
return {"status": "ok"}
| Idea | Why it matters |
|---|---|
No Depends , no auth |
|
| The absence is the point — a monitor hits it without a token | |
| No DB call | It answers "is the process up," not "is every subsystem healthy." A deeper check is a conscious later step |
Returns a plain dict |
|
FastAPI serializes it to JSON automatically — no response_model needed for something this trivial |
Additive, zero risk, and it's the exact URL Phase 9's host will hit to confirm the deploy is up before I even try to log in. Good warm-up.
This is the part I'd skipped in every previous project: actually attacking my own endpoints and recording what they return. Not "I think it 404s" — curl it and read the status line.
I grabbed a token once, stashed it in a shell variable, and fired four deliberately-bad requests. Here's the evidence table that came out:
| # | Probe | Expected | Actual | Verdict |
|---|---|---|---|---|
| 1 | POST expense, negative amount | 422 | 422 | Input validation holds |
| 2 | GET /expenses , garbage token |
|||
| 401 | 401 | Auth boundary holds | ||
| 3 | GET a non-existent expense id | 404 | 404 | Not-found path is clean |
| 4 | GET another user's expense id | 404 | 404 | Cross-user isolation holds |
Two things I want to call out, because they're design wins, not luck:
Probe #1 dies at the schema, not the database. My ExpenseCreate
declares amount
with gt=0
, so Pydantic rejects a negative amount before my route body ever runs. Nothing gets written. Validation at the edge means the dangerous code never executes.
Probe #4 returns 404, not 403 — on purpose. My query filters by both id
and user_id == current_user.id
, so a row I don't own comes back as None
→ 404. A 403 Forbidden
would be the wrong choice here: it would confirm "this expense exists, you're just not allowed to see it," which leaks the record's existence. 404
says "nothing here for you" and reveals nothing.
The candid bit: the "raw stack trace" fear I'd been carrying is mostly unfounded on FastAPI. It doesn't put tracebacks in the HTTP response by default — it returns a generic 500 and logs the trace server-side. Tracebacks only leak into the body if you run with debug on. So the real production job isn't "hide the trace," it's "make sure debug stays off, and add a net for the unexpected." Which is Step 3.
The audit found zero leaks — but it also couldn't test one thing, because nothing in my app currently throws an unhandled error. Production code eventually will. When it does, I want a deliberate, consistent response — not whatever Starlette's default is.
FastAPI already handles two error families: my intentional HTTPException
s (401/404/409) and validation errors (422). What it doesn't handle is the unexpected — a bug, a None
where I assumed a value. A catch-all lets me log the full traceback server-side with a correlation id, and return a clean generic body to the client.
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
error_id = uuid.uuid4().hex[:8]
logger.exception(
"Unhandled error [%s] on %s %s", error_id, request.method, request.url.path
)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error", "error_id": error_id},
)
| Concept | What it does |
|---|---|
@app.exception_handler(Exception) |
|
Catches only what a more specific handler didn't — my HTTPException s and 422s keep their exact status codes |
|
logger.exception(...) |
|
| Logs at ERROR level with the full traceback — the server-side record | |
uuid4().hex[:8] |
|
| A short correlation id, printed in both the log and the response | |
Generic {"detail", "error_id"} body |
|
| The client sees a reference code to quote to support — never internals |
The critical nuance is that first row. A handler on the broad Exception
type does not swallow my deliberate HTTPException
s — if it did, every 404 would turn into a 500. So the design is a floor under the unexpected, not a replacement for my existing errors.
And I proved it — not by faith, by a throwaway /debug/boom
route that raised on demand, watched the handler catch it, then deleted the route. The 404 regression check afterward confirmed the catch-all left my intentional errors alone. (Getting that log line to actually format is its own war story below.)
Everything I proved in Step 2 with curl — the 422, the 401, the 404, the isolation — evaporates the moment I change code. A test turns a one-time proof into a permanent guarantee, and it's the single most credible signal to a stranger reading the repo that the app actually works.
FastAPI's TestClient
calls the app in-process — no uvicorn, no network, fast and isolated. I proved the harness with one trivial /health
test first (prove the tooling before you trust the result), then added the real ones.
The interesting piece is testing authenticated paths without a live login, using dependency_overrides
— and the trap that comes with it:
@pytest.fixture
def as_authenticated_user():
def _fake_user():
return User(id=1, name="Test User", email="test@example.com", hashed_password="x")
app.dependency_overrides[get_current_user] = _fake_user
yield
app.dependency_overrides.clear()
| Concept | What it does |
|---|---|
dependency_overrides |
|
| Swaps a real dependency for a fake — auth is satisfied, the route runs | |
| A fixture, not a global | |
| The trap: a global override poisons every test, silently breaking the 401 test | |
yield split |
|
Everything before is setup, everything after is teardown — the .clear() is what keeps tests isolated |
The proof that the teardown works: after adding the fixture, my test_expenses_requires_auth
test still returned 401. If the override had leaked, it would've gotten the fake user and failed. Isolation intact.
The highest-value test is the one thing I'd only proven by hand: cross-user isolation. But it writes to the database, so it can't touch my real expenses.db
. That means a proper test-database fixture — and the trickiest concept of the whole phase.
@pytest.fixture
def test_db():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def _override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = _override_get_db
seed = TestingSessionLocal()
try:
yield seed
finally:
seed.close()
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
The StaticPool
line is the one to internalize. TestClient
runs requests in a thread pool, and a normal in-memory SQLite database is per-connection — a second connection sees an empty database, so my seeded rows would vanish. poolclass=StaticPool
forces SQLAlchemy to reuse one single connection for everything, so the schema and seeded data persist across request threads. Without it, this test fails in a baffling way.
The test itself seeds two users and one expense owned by user2, then makes the same request as each identity:
def test_cross_user_isolation(test_db):
app.dependency_overrides[get_current_user] = lambda: User(id=u2_id)
owner = client.get(f"/expenses/{expense_id}")
assert owner.status_code == 200 # the control
app.dependency_overrides[get_current_user] = lambda: User(id=u1_id)
other = client.get(f"/expenses/{expense_id}")
assert other.status_code == 404 # the guarantee
The 200
assertion is the control, and it's what stops this from being a false pass. It proves the row genuinely exists and is readable — so the following 404
can only mean my owner-scoped query hid it from a different user. Same id, two identities, two outcomes. That's the isolation guarantee, now permanent.
(This test almost didn't run at all — see the war story.)
My list_expenses
was returning every row for the user with no ceiling. Fine at 12 rows, a latent problem at 10,000. Two query params fix it — with validation at the edge:
@app.get("/expenses", response_model=List[ExpenseRead])
def list_expenses(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
):
expenses = db.scalars(
select(Expense)
.where(Expense.user_id == current_user.id)
.order_by(Expense.spent_on.desc(), Expense.id.desc())
.limit(limit)
.offset(offset)
).all()
return expenses
The non-obvious part: offset/limit is meaningless without a deterministic ORDER BY. SQLite makes no promise about row order unless you ask, so "skip 20, take 20" could overlap or drop rows between requests if the order shifts. Pagination and ordering are a package deal — hence the
order_by
.The actual hardening is le=100
: a caller asking for limit=999
gets a clean 422 instead of defeating the whole point. And I named the behavior change out loud — the list now returns max 50 by default where it used to return everything — so future-me isn't surprised when the frontend eventually needs a "load more."
The one mental model to never forget: git history is a security camera that never stops recording, and deleting a file does not delete its past. If a secret appeared in any commit — even one I later "undid" — it's still recoverable and will go public the moment I push.
So a secrets audit is really three questions, and people who get burned only ask the first:
| Question | Command | What it proves |
|---|---|---|
| Tracked now? | ||
| `git ls-files \ | grep -iE "env\ | |
| File ever committed? | {% raw %}git log --all --oneline -- backend/.env |
|
| Past tense, by filename | ||
| Value ever committed, any file? | ||
git log --all --oneline -S "actual-value" |
||
| Past tense, by content |
All three came back clean. But question 1 taught a bonus lesson: it matched backend/alembic/env.py
— Alembic's migration script, which just has the letters "env" in its name. A total false positive. A filter matches strings, not meaning — so you always eyeball what matched instead of trusting the filter.
The through-line from question 3: a secret leaks by content, not filename. Someone hardcodes SECRET_KEY = "..."
into a .py
"just to test," commits it, then later moves it to .env
. The .env
history is spotless — but the value is in that old commit forever. Only the pickaxe (git log -S
) searching for the actual value catches that.
(Honest note I wrote down: a clean pickaxe only proves the current values were never committed. If a secret has ever lived somewhere I'm unsure about, the bulletproof move before a public deploy is to rotate it anyway. Rotation beats archaeology.)
Phase 7b's theme was "everything that isn't your code lying to you." Phase 8's theme was quieter and more humbling: my own typing, and tools doing exactly what I typed instead of what I meant. Almost none of it was in the application code.
My shiny new exception handler ran, the client got its 500 — but the server log was garbage:
Message: 'Unhandled error [$s] on $s %s'
Arguments: ('a06f2a97', 'GET', '/debug/boom')
See the $s
? I'd typed $s
instead of %s
. Python's logging
uses %
-style formatting, so the interpolation failed and it dumped the raw message + args block. I fixed the middle one, re-ran — and it still broke, because I'd missed the one inside the brackets: [$s]
. On a US keyboard %
is Shift+5 and $
is Shift+4, adjacent keys. I hit the wrong one, in the same line, twice.
The lesson isn't "type carefully." It's that the whole point of the handler — a readable server-side log — was silently defeated by one wrong character, and the only reason I caught it was reading the actual log output instead of assuming it worked. Same energy as Phase 7b's silent except
.
I added test_cross_user_isolation
, ran pytest, saw green, and almost celebrated. Then I read the first line:
collected 3 items
Three. I'd written a fourth. It wasn't failing — it didn't exist as far as pytest was concerned. The function had gotten indented so it was nested inside another, which makes it invisible to collection (pytest only finds top-level test_*
functions). No error, no failure, just... absent.
Now collected N items
is the first thing I read, before the pass/fail. A green suite that silently skips your most important test is worse than a red one.
Coming back after a few days, my saved token was stale (JWTs expire; shell variables die with the terminal). Fine — re-authenticate. Except the login kept returning a KeyError: 'access_token'
. I tried passwordd
. Failed. I knew the password was Passwordd
with a capital P — tried that. Still failed.
The fix, as always: stop guessing, strip the pipeline, read the raw response. The real problem was the email — I'd been typing frontend1@test.com
when the account was Frontend1@test.com
. Capital F.
The design lesson underneath: my login returns the same generic 401
for a bad email or a bad password — which is correct (never tell an attacker which field was wrong) — but it means the error can't tell you which one you fat-fingered. That's exactly why "read the raw response" beats "stare at the error message."
One curl command died with:
bash: $'\302\226\302\226curl': command not found
\302\226
is the UTF-8 for an invisible control character. Two of them had ridden along when I pasted from a rich-text source, so bash tried to run a command literally named ‖‖curl
. The command was perfect; the paste smuggled in garbage bytes. Same family as Phase 7b's hidden comma — you can't debug what you can't see, so cat -v
(which prints control characters) and re-typing by hand are the escape hatches.
The through-line across all four: before I'd fixed a single line of application code this phase, my keyboard, my clipboard, my shell, and pytest's collector had each quietly done something other than what I intended. The bug is rarely where you first look — and it's often not even in your code.
Same habit as always — try to break it before reality does:
| Attack | Defense |
|---|---|
| POST a negative amount | Pydantic gt=0 → 422, dies at the schema, never touches the DB |
| Present a garbage/expired token | |
get_current_user → clean 401 with WWW-Authenticate |
|
| Request another user's expense id | Owner-scoped query → 404 (not 403 — don't confirm it exists) |
| Trigger an unhandled 500 | Global handler → generic body + correlation id, full trace stays server-side |
Request limit=999999 to dump everything |
|
Query(le=100) → 422 |
|
| Read a secret out of "deleted" git history | Swept by filename and value across all branches → clean |
| I did (learning) | Production would |
|---|---|
| Manual curl audit, then automated it | Audit runs in CI on every push, blocks merge on a regression |
| 5 focused tests | Broader coverage: every endpoint, edge cases, a real coverage target |
In-memory StaticPool test DB |
|
| Same pattern scales fine — just more fixtures and factories | |
print /logger to stdout |
|
| Structured logging with levels + an alert on sustained 500s | |
| Deferred prod CORS to Phase 9 | Env-driven allowed origins, locked to the real frontend URL |
| Called the secret history "clean" | Rotate secrets anyway before a public deploy — rotation beats archaeology |
Ignored the httpx /httpx2 deprecation warning |
|
| Resolve it before it becomes a hard break on a dependency bump |
None of these are wrong for where I am. They're known — written down, not pretended away.
$s
log, the uncollected test, the capital-F login — every one was caught by /health
first means any later red is a real failure, not a setup problem.collected N items
is the first line you read.KeyError
in a wrapper hides the {"detail": ...}
that would've told you the answer.dependency_override
poisons every other test. yield
- teardown keeps them isolated.
200
control prevents a false-pass 404
.alembic/env.py
was innocent — always eyeball what matched.The app is trustworthy now. The audit found no leaks, the unexpected 500 has a net, five tests lock in every guarantee that used to live only in my memory, and the git history is clean.
What's left is the last mile: getting it onto the internet. Phase 9 is deployment — starting with the one item I consciously deferred (production CORS, once I know the real frontend URL), then Render itself, where my own lessons come home to roost: Linux means venv/bin/activate
, not Scripts/
, and paths are case-sensitive (the capital-F login bug, now at the OS level). Secrets become real environment variables in a dashboard, not a .env
file. And I'll confirm debug is off, closing the loop on the exception handler.
The features were the fun part. Turns out making them trustworthy was the part that actually taught me something.
See you in the next one.
silentcarry