Shipmind Labs

A rate limiter that refreshes its TTL on every attempt limits nothing. It just punishes whoever stops knocking.

That is the quiet failure mode hiding inside "5 attempts per 15 minutes". If each increment re-applies the lifetime, the window slides forward with the attacker. Keep knocking once every 14 minutes and the counter never expires, while knocking fast enough locks out a well-behaved user who typo'd their code twice, long after the real window should have closed. Same key, same code path, opposite outcomes depending on traffic shape.

In otpguard, our open-source OTP library, we pinned the window to its first increment, and we pushed that rule down into the storage layer instead of the policy layer. The storage contract is a small runtime-checkable protocol: get, set, incr, delete, each accepting an optional ttl. The invariant fits in one sentence: incr attaches a lifetime only when it creates the counter.

The mechanics differ per adapter, which is why the invariant has to be stated rather than assumed.

The in-memory adapter computes the deadline first, then reads the key. Absent, the counter starts at 1 with that deadline. Present, the counter becomes current plus one and the expiry comes from the existing entry, so the new deadline is discarded. Later increments cannot push the window out, because the only value they can write is the one already sitting there.

The Redis adapter cannot compute first. It calls incr, then calls pexpire only when the returned counter equals 1. Redis tells you whether you created the key by what it hands back, and that return value is the entire branch condition.

Two details we would not skip again.

A non-positive lifetime is rejected, not normalized. A ttl of zero or negative is a bug in the calling policy, and silently treating it as "no expiry" turns a rate limit into a permanent lockout. It raises. Milliseconds round up rather than down, so a sub-millisecond window never collapses to zero.

And a counter that does not parse as an integer raises instead of resetting to zero. That one is tempting to get wrong, since resetting looks like graceful degradation. In practice it hands a free attempt-counter reset to anyone who can get a bad value into that key. Corrupt state should stop the request, not forgive it.

So "expire after 15 minutes" works as a behavioural guarantee rather than a configuration value, and it lives or dies in whichever line of the adapter decides when to set the TTL. Policy code reads the same either way.

If you run attempt limits in production, it is worth knowing whether your window is pinned to the first attempt or the last one, and whether that was a decision or just a default you inherited.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com