A Tiny Nim Microservice, Three Real Bugs, and What They Taught Me About My Own Mummy Fork
I recently needed something small: a POST /api/contact endpoint for my portfolio site at https://www.isaiah.name.ng that takes a name, email, and message, validates it, and emails it to me. The kind of thing you'd normally knock out without thinking twice.
I decided to build it directly on my own Mummy fork — the one with OpenAPI schema generation, typed validation, and composable middleware — partly to actually dogfood it on something real, and partly because I assumed a single-endpoint service would be trivial. It mostly was. But three specific bugs along the way taught me more about the fork's internals than building the fork itself did.
The Shape of the Service
Nothing exotic: a ContactBody type with name, email, and message, all required strings. Validation happens in two layers — parseValidatedBody[ContactBody] checks presence and type against the fork's schemaOf-generated schema, and then explicit checks enforce non-empty fields, a 2000-character cap, and a hand-rolled email format check. Either layer raising the fork's ValidationError gets mapped to a clean 400 {"error": ...} by installDefaultErrorHandler. A send failure is logged server-side and answered with a generic 500 — the caller never sees why an email failed to send, only that it did.
On top of that: CORS middleware that rejects disallowed origins with 403 and answers preflight requests properly, an in-memory rate limiter capping submissions at 5 per IP per 10 minutes, and the fork's loggingMiddleware as the outermost layer so every request logs method, path, and timing. The OpenAPI docs come free from the same schema the validator uses, served at /docs with the spec at /openapi.json — the whole reason I built the fork in the first place.
The Pivot Nobody Plans For: SMTP → Resend
The original plan was plain SMTP via std/smtp. It compiled fine against the fork. Then I deployed to Render's free tier and every send just hung until timeout — Render blocks outbound SMTP ports (25/465/587) on that tier. Resend's REST API runs over HTTPS:443, which isn't blocked, and needs just one API key. As a bonus, its failures come back as ordinary HTTP errors the handler already catches and logs, instead of the occasional AssertionDefect that a raw SMTP library can throw and potentially take a worker down with it.
This is the kind of constraint you only discover by actually deploying, not by reading docs — worth remembering if you're building anything Render-hosted that needs to send mail.
Three Bugs That Taught Me More Than the Feature Did
1. Two HttpHeaders types that look identical and aren't.
Mummy imports std/httpcore aliased internally, and Request.headers is typed against that aliased import. My module also did a plain import std/httpcore for the Resend payload types — and suddenly every header operation threw a "type mismatch" between what looked like the same type. Nim's module system treats the two imports as distinct type identities even though they're structurally the same. The fix was narrow: import std/httpcore except HttpHeaders, so my module only pulled in the pieces it needed for the Resend payload and left the header type alone. This was, by a wide margin, the buggiest hour of the whole build — and a good reminder that "same type, different import path" is a real failure mode in Nim, not a theoretical one.
2. gcsafe doesn't care that your global never actually changes.
The fork's Middleware and RequestHandler types are marked {.gcsafe.}, which means the compiler's data-race checker gets strict about any global state touched inside a handler — because Mummy dispatches requests across a worker-thread pool, not a single event loop. My CORS middleware read a var global holding the allowed-origins list, set once at startup and never touched again — and the compiler didn't care that it was logically immutable after init. It only sees a mutable global read from a gcsafe context. The fix was a {.cast(gcsafe).} block around that specific read, while the genuinely-shared rate-limit table stayed behind a real Lock — the same pattern the fork already uses internally for bearerAuthMiddleware. It's a useful distinction to internalize on a threaded server: "never mutated after startup" and "thread-safe" are not the same claim, and the compiler will only ever check for the second one.
3. An unregistered route means your middleware never runs — including for the request that most needs it.
Browsers send an OPTIONS preflight before certain cross-origin POST requests. Mummy only runs its middleware chain for methods that have a registered route — so with no OPTIONS /api/contact route, Mummy answered preflight with its own 405 before corsMiddleware ever got a chance to run. The browser never saw the Access-Control-Allow-Origin header it needed, so the real POST was blocked client-side despite the server being otherwise correctly configured. The fix was almost comically small: register an OPTIONS /api/contact route whose handler does nothing but exist, which is enough to route the request into the middleware chain where corsMiddleware can actually answer it with a 204. Easy to fix once you know it — genuinely confusing the first time a working-looking CORS setup silently fails only on the preflight leg.
What Actually Got Verified
Before calling it done, I ran it locally and checked the specific paths that tend to hide bugs rather than just the happy path: a disallowed origin (403), a malformed email (400 with the exact field named), a name at the 2000-character boundary (passes) versus one character over (rejected), six rapid submissions from one IP (five succeed, the sixth comes back 429 with Retry-After), and that /health and OPTIONS both stay outside the rate limiter so Render's health checks and CORS preflight never get throttled.
Why This Was Worth Writing Up
None of these three bugs were about the feature — a contact form endpoint is about as unglamorous as backend work gets. They were about the specific intersection of Nim's module aliasing, gcsafe's literal (not logical) read of what's safe, and Mummy's routing-before-middleware order. If you're building anything on Mummy — my fork or the original — that touches CORS, thread-shared state, or has more than one import path into std/httpcore, these three are worth checking for before you hit them the hard way.
The full service, Dockerfile, and a drop-in script.js for wiring this into any static contact form are in the repo if you want to see the whole thing end to end.
Top comments (1)
Bug #1 is the one I'd have burned a day on. Structurally identical but nominally different is Nim's module system being consistent in a way that feels like a trap. Your fix is the right one, but in a fork you maintain I'd also keep a note in the repo listing which types must come from which import path — in six months nobody remembers why that
exceptclause is load-bearing, and someone 'cleans it up'.The gcsafe one taught me the same lesson: the checker reads mutability, not intent. My cleanest fix wasn't the cast either — it was moving the read-only config into a
letbuilt at startup and closing over that, so there's no mutable global to argue about at all. The cast stays honest for genuinely shared state. And preflight answered before the CORS hook exists in far more frameworks than Nim; plenty of Node stacks do it too. Good writeup.