Five ways to fail without an error message
In July 2026, this site served a blank page on every route. Not an error
page -- a blank page, with a 200 status code, from a server whose logs
showed nothing wrong, on a stack whose migration to Python 3 had been
declared complete nine months earlier by a commit titled, without
irony, Complete Python 2 to 3 migration.
The repair is one commit, ca66cf9, and it carries five separate bugs:
main.jsinstantiated the app with Svelte 4'snew App(), removed in Svelte 5, so the app never mounted.SpaStaticFileHandler.getwrapped a coroutine intry/except, so the 404 raised on await, outside the handler -- the SPA fallback was dead code from the day it was written./data/pointed at./static/data/, a directory that did not exist.- An empty
COOKIE_SECRETpassed the production guard, because the check compared against the literal dev default instead of asking whether a real value was present. - The systemd unit used
--port 8080, which Tornado's option parser rejects; it needs--port=8080.
Not one of the five produced an error message. That sentence is the whole subject. What follows is the anatomy of each silence -- what it was made of, why every one of them looked like working code, and what the codebase still keeps from them.
One: the mount that never happened
In January 2026, Dependabot counted five security vulnerabilities in the frontend's dependencies, and the upgrade that cleared them -- Svelte 3.59 to 5.0, Vite 4 to 6 -- closed with this line in its commit message:
Note: Svelte 5 is backward compatible with most Svelte 4 code.
The blank page lived in the word most. main.js still said:
const app = new App({
target: document.getElementById('app'),
})
new App() is Svelte 4's mounting API, and Svelte 5 removed it. Nothing
about the failure reached the server, because the server's half of the
bargain was performed flawlessly: the shell came back 200, the bundle came
back 200, the bundle loaded, the bundle ran. The app just never mounted,
and the <div id="app"> the shell ships stayed empty forever. The only
witness was a browser console nobody had open -- and a console is not a
monitor anyway. It only speaks to someone who already suspects.
The fix is the Svelte 5 idiom, one line:
const app = mount(App, {
target: document.getElementById('app'),
})
181 days passed between the upgrade and the repair. Every visit in between -- every share, every crawl, every unfurl -- got the same beautiful nothing.
Two: the fallback that couldn't catch
The SPA fallback was added the same day, in a commit titled Fix
client-side routing not working. The bug was born in a fix for routing.
The handler's first body was:
def get(self, path, include_body=True):
try:
return super().get(path, include_body)
except HTTPError as e:
if e.status_code == 404 and ...:
return super().get('index.html', include_body)
raise
Read it as pseudocode and it is correct: try to serve the file; on 404,
serve the shell instead. Read it as Python against Tornado and the try
guards nothing. super().get() is a coroutine; calling it returns a
coroutine object and raises nothing. The HTTPError fires later, when
Tornado awaits that coroutine -- up in the framework, well outside the
except's reach. The fallback was dead code from the day it was written,
and no test failed, because the code was syntactically perfect,
type-correct, and never executed its own purpose.
The consequence stacked on top of bug one: / served the shell (the
default_filename path raises nothing), and every deep link -- /hello,
/kanban, anything a client-side route owns -- 404'd outright. The site
was unreachable two ways at once and reported one way to the logs: a
clean bill of health.
The fix makes the handler honest about being async:
async def get(self, path, include_body=True):
try:
await super().get(path, include_body)
except HTTPError as e:
...
await inside the try is the whole difference. A coroutine raised on
await can only be caught by something that was there for the await.
Three: the route to nowhere
January 2023, a commit called tinkering with legacy demos added:
(r"^/data/(.*)$", tornado.web.StaticFileHandler, {'path': './static/data/'}),
./static/data/ did not exist. For the next 33 months nothing fetched
/data/, so the wrongness cost exactly nothing -- a landmine with no foot
traffic. Then in October 2025 the D3 pages shipped, started fetching
/data/, and got 404s: HTML where data should be, pages rendering
dataless and, by every visible signal, working fine.
The access log had carried those 404s the entire time. This is worth saying plainly: the access log is where 404s go to be ignored. A 404 on a route you believe is decorative reads as noise; a 404 on a route you believe is working reads as someone else's problem. Nothing about a 404 in an access log escalates, and nothing about this bug ever produced anything louder.
1,296 days from wrong to fixed -- it predates the Python 3 migration by almost three years. The migration whose entire job was noticing things had nothing to notice, because nothing about this bug emits a signal. It looked like a config line. It was a config line. That was the problem.
Four: the guard that guarded the wrong value
The bot's migration, July 2025, added a production guard for the cookie secret and listed it under a checkmark heading: "✅ Security Improvements: Add production safety check for COOKIE_SECRET." Here is the check it added:
cookie_secret = os.environ.get('COOKIE_SECRET', 'changemeplz-dev-only')
if cookie_secret == 'changemeplz-dev-only' and not debug:
raise ValueError("COOKIE_SECRET environment variable must be set for production")
The intent is right and the mechanics are wrong, and the gap between them
is one of the oldest holes in configuration handling. os.environ.get's
default applies only when the key is absent. A .env file that says
COOKIE_SECRET=
-- a placeholder waiting for a value, which is the ordinary state of a
fresh checkout -- has a present key with an empty value. get
returns '', the comparison against the literal default fails, the guard
stands aside, and production runs, signing cookies with the empty string.
The guard asked "is this the literal dev default?" when it needed to ask "is there a real value here?" Those are different questions, and the second one is the one that matters.
The fix -- .strip() or 'changemeplz-dev-only' -- treats blank as unset,
and that turned out to be the most durable line in the commit. The same
day, the settings helpers moved into config.py, where config.get()
now treats blank as unset for every setting, the docstring in
AGENTS.md explains why, and tests/configuration.py carries a
regression test whose own docstring says it: "A blank value used to slip
past the guard entirely." 380 days passed between the guard and the
repair. The bug is the reason the convention exists.
Five: the flag that needed an equals sign
March 2026 added the VPS deployment system -- systemd unit, nginx vhost -- and committed the unit with:
ExecStart=/opt/pearachute/venv/bin/python /opt/pearachute/pearachute.py --port 8080
Tornado's option parser partitions each argument on =. No =, and the
option isn't a bool? Then, from tornado/options.py, before the process
binds anything:
raise Error("Option %r requires a value" % name)
The unit as committed could never have started the app. This one is the least silent of the five -- it fails loudly, at startup, every time. But loud where? In a journal, on a box, and wrapped in the two settings that turn a crash into a rhythm:
Restart=always
RestartSec=10
A service that crash-loops every ten seconds does not look broken from the outside. It looks like a service that is always about to start. And the journal lives on the machine you only open when something is already wrong. 130 days -- the shortest-lived of the five, and still four months.
The fix is one character: --port=8080. The repo's AGENTS.md now
carries the rule, because the next person to write a unit file will
reach for the space-separated form, the way every tutorial does.
What the silences were made of
Five bugs, five different mechanisms, one shape. Sorted by how they were silent rather than where they sat:
- Silence by layer. Each bug had exactly one witness, and each
witness lived in a place nobody was looking: a browser console, a dead
exceptblock, an access log, a.envplaceholder, a remote journal. The one log a human actually reads -- the server's -- was clean throughout, because at every one of these moments the server genuinely did its job. - Silence by success. Every layer reported success. 200 OK. A bundle that loads and runs. A guard that passes. A deploy that goes green. Success at every layer except the one where the visitor is. Failure and success are not opposites across layers; they are independent.
- Silence by default. "Unset" is one word for two states: the key
absent, and the key empty.
os.environ.get's default covers only the first. Half the.envfiles on fresh checkouts carryKEY=placeholders, which is the second. - Silence by loop.
Restart=alwaysconverts "broken" into "starting", indefinitely, ten seconds at a time.
None of these are exotic. Every one of them is a normal Tuesday in a codebase that is passing its checks.
How long each one lived
Measured from each bug's introduction commit to the repair, from the git history:
The chart's argument is the right edge: all five bars end on the same day, not because they were discovered together, but because one person finally looked at the site the way a visitor does. The left edges tell the other half. The oldest bug predates the migration by almost three years, and the newest -- the one that blanked every route -- was introduced by the same day's work that declared the stack modern.
What actually caught them
Not a monitor. Not an alert. A person opened the page. Every automated check this repo had -- and it had some -- was measuring the wrong layer, and every one of them stayed green for the entire run of all five bugs.
What the repair left behind is worth more than the repair. Four of the five bugs now have an automated witness:
tests/routing.pyfetches a client-side route and asserts the shell comes back -- the dead fallback has a test that fails if it dies again.- The same file fetches
/data/stocks.tsvand asserts a 200 -- the route to nowhere is watched now. - The same file asserts that a missing asset 404s rather than quietly returning HTML, which is the inverse tripwire: the fallback is only allowed to fall back where it's supposed to.
tests/configuration.pysetsCOOKIE_SECRET=''and asserts the guard raises -- the blank-as-unset hole is a regression test with a docstring that tells you where it came from.
And the honest limit: the fifth bug has no witness. The shell always
contains <div id="app">; whether anything ever mounts into it is a
browser question, and no browser runs in CI. A Python test suite cannot
see a blank page. If mount() regressed to new App() tomorrow, every
test in this repo would pass, and the site would go blank again, and the
logs would say nothing, again. That witness is still missing, and it
belongs to the bug that hurt the most.
The repair, once found, was almost nothing: one character for the port
flag, one API call for the mount, one await for the fallback. Knowing
where to put them was the entire cost. Each of the five was a success story told at every layer except
one, and the only instrument that found the lying layer was a human
being visiting the site like a visitor -- still the most under-deployed
monitoring tool there is. The durable output of the whole incident is
one convention, written into config.py and into this repo's
instructions for every agent that works on it: blank is unset. Most
of what a silent failure leaves behind is a fix. The valuable part is
the rule it teaches.