Notifio is a desktop app that watches rental search pages and tells you when a new listing appears. The website sells it, hands over the installer, and answers three API calls the app makes: is this licence valid, send this email, start this checkout. Everything except the installer and the contact form needs the database.
So when the database has to go away for a while, the website has to answer for it. That switch is 13 lines:
/**
* Maintenance switch. Flip to `true` and deploy to take everything that needs
* the database offline cleanly: the API routes behind the desktop app and
* checkout answer 503 with a Retry-After header (see src/proxy.ts), and the
* checkout form on /pricing is replaced with the message below. Marketing pages,
* downloads and the contact form stay up. Flip back to `false` and deploy to
* restore, at which point /maintenance redirects home.
*/
export const MAINTENANCE = false;
export const MAINTENANCE_TITLE = "Down for maintenance";
export const MAINTENANCE_MESSAGE =
"Notifio is down for maintenance. Please try again in a little while.";
Not an environment variable, which is the obvious alternative and the faster one to flip. A constant costs a deploy to change, about two minutes. What it buys is that the same value is readable by a client component, by the request-time proxy, and by a page's metadata, with no runtime lookup to fail and no second place to set it. For a one-person product where the switch gets flipped by hand anyway, the deploy is not the slow part.
The list is of who stays up, and it is inverted
/**
* API routes that never touch the database, so they stay up during
* maintenance: the download page's links, the help page's contact form, and
* inbound email forwarding. Every other API route is refused, so a route added
* later is safe by default.
*/
const NO_DATABASE_ROUTES = ["/api/download", "/api/contact", "/api/webhooks/resend"];
function needsDatabase(pathname: string): boolean {
return !NO_DATABASE_ROUTES.some(
(route) => pathname === route || pathname.startsWith(`${route}/`)
);
}
The inversion is the only interesting decision in the file. An allowlist of routes to refuse would be shorter today and wrong in a month, because the next route added would quietly keep serving traffic against a database that is not there. Written this way, a new route is refused until somebody adds it to a list of three, and adding yourself to that list means claiming, in writing, that you never touch the database.
The three that qualify: /api/download hands out a presigned R2 link that expires in 60 seconds and never reads a row, /api/contact sends an email, and the Resend webhook forwards inbound mail. None of them can answer a question about a licence, which is the only thing the database holds.
503, and the file is called proxy.ts
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// The site has no login page, so outside maintenance this goes home.
if (pathname === "/maintenance") {
return MAINTENANCE ? NextResponse.next() : NextResponse.redirect(new URL("/", request.url));
}
// A 503 rather than an error, so the desktop app treats it as "try later"
// and Stripe redelivers its webhooks once the switch is off again.
if (MAINTENANCE && needsDatabase(pathname)) {
return NextResponse.json(
{ error: MAINTENANCE_MESSAGE },
{ status: 503, headers: { "Retry-After": String(RETRY_AFTER_SECONDS) } }
);
}
return NextResponse.next();
}
export const config = {
matcher: ["/api/:path*", "/maintenance"],
};
Two notes on that file. It is src/proxy.ts, not src/middleware.ts, because Next 16 renamed the thing: same position in the request, new filename and new exported function name. And the matcher is deliberately two entries rather than everything, so the marketing pages never run it at all. The pages are static, they do not need the database, and nothing about a database outage should put a request-time hook in front of them.
The status code is load-bearing in both directions. Stripe redelivers a webhook that answers 5xx, so a purchase made in the last second before the switch goes on is not lost, it just lands late. And the desktop app, as below, reads 5xx as "we do not know" rather than "no".
The page you are not supposed to be able to reach
There is a /maintenance page with the message on it, and while the switch is off it is not reachable:
$ curl -sI https://notifio.app/maintenance | head -3
HTTP/2 307
location: /
It carries robots: { index: false, follow: false } and it is not in the sitemap, which is derived from the page catalogues rather than hand-listed, so leaving it out took no effort. A site with a login page would send you there instead. This one has no accounts at all, so home is the only honest destination.
The same constant also swaps the buy button on the pricing page for the message, which is the part a human actually sees. A 503 from an API route is for the app; a person clicking a button needs a sentence.
Now the app's half, which is where it gets interesting
The desktop app makes three kinds of call to the server, and they do three different things with a 503.
The licence check turns it into a refusal to answer:
if (!res.ok) {
// Only a considered "no" from the server counts as one.
//
// A 429 used to land here and be recorded as an invalid licence, which
// it plainly is not: the endpoint is rate limited per IP, so a burst of
// checks (or a shared office address) could switch someone's working
// licence off. Anything that is not the server having looked the licence
// up and refused is "we do not know".
if (res.status >= 500 || res.status === 408 || res.status === 429) return null;
return { valid: false, autoReply: false };
}
null means unknown, and the caller falls back to the cached answer on disk, flagged stale. That branch was written because of the 429, and maintenance is the case it was accidentally designed for: a licence someone paid for does not lapse because the database is having a day.
The listing alert is the one allowed to throw:
if (alerts.length > 0) {
try {
await sendListingAlert(alerts, config);
// Delivery confirmed, so now it is safe to record these listings as seen.
for (const snap of pendingSnapshots) saveSnapshot(snap.url, snap.listings);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log(`[notify] Failed to send: ${message}. Keeping listings unseen, will retry next poll`);
}
}
The snapshot of what a page held is what makes the next check able to tell new from old. Saving it only after the email is accepted means a 503 costs you nothing: the listings stay unseen, and the next poll, 30 seconds later, finds them again and tries to send again.
The other two emails the app can ask for, the one that says a site needs you to log in again, and the one that says a site has started refusing automated checks, both swallow a failed send inside a try/catch and move on. That is defensible for a warning that is rate limited to one per hour per site anyway, but it does mean the honest version of the table is:
| Call | On 503 | What it costs |
|---|---|---|
POST /api/validate |
treated as "unknown" | nothing, cached answer stands |
POST /api/notify (listings) |
throws, snapshot not saved | nothing, retried next poll |
POST /api/notify (warnings) |
logged and dropped | that one warning email |
POST /api/checkout/upgrade |
surfaced as an error toast | the user tries again later |
One of the four is genuinely lost. Three are deferred.
The header with no reader
Retry-After: 600 goes out on every refused request. I went looking for the code that reads it.
$ grep -rn "retry-after" app/src --include="*.ts" -i | wc -l
7
Seven hits across four files, and every one of them is about a response from a rental site rather than from us. The app already knows how to parse that header, in both of the formats it comes in, with a ceiling on how long it will honour, and that parser has exactly one caller which is not this one. Our own server's Retry-After is read by nobody: the app comes back on its own 30 second cycle regardless, and the licence check comes back on its own schedule.
That is not a bug, because every client involved retries on a timer anyway and 600 seconds is advice rather than instruction. It is the smaller and more common thing: a correct header, emitted because it is correct, in a system where the only two clients are ours and neither of them asked. Stripe does read 5xx and does redeliver, so the status code earns its place. The header is there for the next client, which does not exist yet.
If you want to see the parts that stay up while the rest of it is off, they are the download page and the contact form at the bottom of the help page. Those two keep working on a day when nothing else does, which was the whole point of writing the list upside down.
Top comments (0)