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:

  1. main.js instantiated the app with Svelte 4's new App(), removed in Svelte 5, so the app never mounted.
  2. SpaStaticFileHandler.get wrapped a coroutine in try/except, so the 404 raised on await, outside the handler -- the SPA fallback was dead code from the day it was written.
  3. /data/ pointed at ./static/data/, a directory that did not exist.
  4. An empty COOKIE_SECRET passed the production guard, because the check compared against the literal dev default instead of asking whether a real value was present.
  5. 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 except block, an access log, a .env placeholder, 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 .env files on fresh checkouts carry KEY= placeholders, which is the second.
  • Silence by loop. Restart=always converts "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:

Lifetimes measured from introduction commit to repair commit in the git history. The /data/ route was wrong from the day it was written, though it began costing data only when the first page that fetched it shipped, in October 2025. Dashed lines mark the migration being declared complete (2025-10-23) and the Svelte 5 merge (2026-01-28); both events checked their own kind of green.

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.py fetches 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.tsv and 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.py sets COOKIE_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.