Our own rate limiter answered "valid: false", and the licence check believed it
Notifio is sold as a one time licence, with one optional one time upgrade on top of it: the part that fills in a contact form for you when a listing appears. The desktop app has to know whether the licence in front of it
Notifio is sold as a one time licence, with one optional one time upgrade on top of it: the part that fills in a contact form for you when a listing appears. The desktop app has to know whether the licence in front of it has that upgrade, and the server is the only thing that actually knows.
So the app asks:
const res = await fetch(`${serverUrl()}/api/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, token }),
signal: AbortSignal.timeout(10_000),
});
And then it has to decide what the reply means. That turned out to be the whole problem, because our own endpoint has a way of answering that is neither yes nor no, and for a while we read it as no.
The answer that is not an answer
/api/validate is rate limited. The limiter runs before anything else in the route, and this is what it returns when it trips:
const { success } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ valid: false, error: "Too many requests." },
{ status: 429 }
);
}
Look at the body. valid: false. That response has not looked at the licence at all. It does not know whether the licence is valid. But it is shaped exactly like a considered refusal, and the only place the truth is recorded is the status code.
The client, meanwhile, was written the way these clients usually get written:
if (!res.ok) return { valid: false, autoReply: false };
One line, reasonable looking, and wrong. The entitlement result then gets written to a cache on disk, because the app has to keep working on a train with no signal. So a single 429 did not just produce one bad answer, it persisted one.
The failure mode is specific and nasty. The limiter is a sliding window of 20 requests per 10 seconds, keyed on the client IP. A student house or an office where several people run the app share one address. Those users had done nothing wrong, had paid, and watched a feature they bought switch itself off in the middle of a search.
Three answers, not two
The fix is to make the function able to say "I do not know", which means it needs a third return value, which in this case is null:
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 };
}
/**
* Ask the server directly. Returns null if the server could not be reached, so
* callers can distinguish "not upgraded" from "we don't know".
*/
And then the caller gets to decide what to do with not knowing, which is a different decision from the one above it:
const fresh = await fetchFromServer(email, token);
if (fresh) return { ...fresh, stale: false };
// Server unreachable: a purchase already made does not lapse because we
// could not phone home.
if (cached) return { ...cached, stale: true };
return { valid: false, autoReply: false, stale: true };
Two things in there are worth saying out loud.
The cached yes is trusted indefinitely, and that is only defensible because of what we are selling. The upgrade is a one time purchase, so there is no period end to enforce and no renewal that can fail. The only event that takes it away is the user deactivating the licence, which clears this cache locally anyway. If this were a monthly subscription, trusting a cached yes forever would be a bug, and the right shape would be a grace window: keep working for some days offline, then degrade. The decision is about the product, not about the cache.
The stale flag exists so the UI can be honest about which kind of yes it is holding. It is cheap to carry and it means nobody has to guess later.
The server side of the same bug
A client that handles 429 correctly is still a client that got a 429 it did not deserve, so the other half of the fix was on the server. Two things were wrong there.
The alert endpoint and the licence endpoint were sharing a bucket:
// 20 requests per 10 seconds, shared by validate and notify.
export const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, "10 s"),
analytics: false,
prefix: "notifio:rl",
});
These are not the same traffic. /api/notify is called when a listing is found, which is bursty and the user cares about it enormously. /api/validate is called on a schedule the app controls. Sharing one window means a good morning on the listings side can eat the budget the licence check needs, and the symptom shows up in the other feature entirely.
And the alert endpoint was keyed on the IP, which is the wrong key when the caller has a better identifier:
// Rate-limit per license (falling back to IP). Keying by token means users
// behind a shared/NAT IP each get their own budget instead of competing, and
// it no longer shares a bucket with /api/validate.
const { success: withinLimit } = await ratelimit.limit(`notify:${token || ip}`);
if (!withinLimit) {
return NextResponse.json({ error: "Too many requests." }, { status: 429 });
}
The licence token is a better key than the IP for every reason that matters here: it is per paying user, it does not collapse a household into one bucket, and the budget it protects is the thing it is actually attached to (emails sent on behalf of one licence). The IP is kept as a fallback for requests that arrive without a token, which are the ones that have not earned a budget of their own yet. This is the same question I wrote about across a different codebase in Ten rate limiters, and the only hard question was what to key each one on, and the answer keeps being that the key matters far more than the number.
What I would take from this
Any check that can fail has three outcomes, not two. Yes, no, and the request did not happen. HTTP is unhelpful here because it hands you all three through the same channel, and a JSON body can look definitive while the status code quietly says the server never got as far as deciding.
Two habits fall out of it. First, in a client, enumerate the statuses that mean "did not decide" and make the function return something a caller cannot mistake for a decision. null is fine; a boolean is not. Second, in a server, do not put a field like valid: false in the body of a 429. We still do, because the shape is public and the app in the field reads it, so changing it is a compatibility problem rather than a one line edit. Writing it the other way round at the start would have been free.
One more thing worth being plain about: none of this is a security boundary. The cache is a JSON file on the user's own disk and the app is Electron, so it can be edited by anyone who wants to. I wrote about why we do not pretend otherwise in A local app cannot keep a secret from its owner, so stop pretending. Everything that must not be forged is enforced on the server, which is exactly why the server's answers need to be readable without ambiguity.
The licence and the upgrade are described on notifio.app/pricing, activation and deactivation are covered on notifio.app/help, and the app itself is at notifio.app/download.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.