Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

quota-clock-source

A rate limiter asks the clock two different questions. This module answers them with two different clocks.

import { QuotaClock, systemClock } from 'quota-clock-source';

const limiter = new QuotaClock({
  capacity: 20,            // burst, refilled on the monotonic clock
  refillPerSecond: 5,
  suspendPolicy: 'ignore', // required, no default: see below
  quota: {
    limit: 10_000,         // calendar quota, reset on the wall clock
    period: { unit: 'month', timeZone: 'America/Los_Angeles', anchorDay: 1 },
  },
  clock: systemClock(),
  onAnomaly: (a) => log.warn(a.detail),
});

const decision = limiter.tryConsume(1);
if (!decision.allowed) {
  // retryAfterBasis is "monotonic-refill" (a measured duration) or
  // "wall-period" (a countdown to a calendar date that can itself move).
  return respond(429, decision.retryAfterMs, decision.retryAfterBasis);
}

The bug in the obvious version

The obvious limiter calls Date.now() for everything.

const now = Date.now();
const elapsed = Math.max(0, now - this.lastRefill);
this.tokens = Math.min(capacity, this.tokens + (elapsed / 1000) * rate);
this.lastRefill = now;

An NTP step of 250 ms backwards makes elapsed negative. The Math.max clamp looks like it handles that. It does not, because the next line still assigns lastRefill = now. For the following 250 ms of real time every refill computes an elapsed close to zero, so the bucket is frozen, callers are rejected, and the clamp never raised anything, so there is not one line in the log saying why. A step forwards is the same bug pointed the other way: an hour of wall time appears from nowhere and the bucket refills to capacity for time that did not pass.

So refill has to read a monotonic source. That fixes both halves of the step problem and breaks something else entirely.

Monotonic everywhere is not the fix either

A quota of "10,000 calls a month, resetting at midnight on the 1st in the tenant's time zone" is a statement about a calendar. There is no offset from process start that means "the 1st of March in Los Angeles". Nothing derived from uptime can express it.

And the monotonic clock has a failure the wall clock does not have: on most hosts it stops advancing while the machine is suspended. An agent that sleeps six hours wakes with a monotonic delta of a few milliseconds and an empty bucket, while the server it is about to call counted those six hours the whole time and would have let it straight through.

Two clocks, kept apart

ClockTracker takes a monotonic reading and a wall reading together on every request and turns them into one number: milliseconds of real elapsed time. The bucket refills from that and never sees a wall clock value. The quota period is computed from the wall reading and a time zone and never sees a monotonic value. The retryAfterBasis field on every decision says which of the two produced retryAfterMs, because a measured duration and a countdown to a movable calendar date are not the same promise.

The interesting work is in reconciling the two readings.

Wall moved backwards relative to monotonic. Unambiguous. No suspend can make the wall clock lose time, so the clock was set. Refill credits the full monotonic delta, which is exactly the case the naive version freezes on, and the step is reported as a wall-step-back anomaly.

Wall ran ahead of monotonic. Ambiguous, and this is the whole problem. A host suspend and a forward NTP step produce identical readings and call for opposite handling: the suspend really elapsed and refill is owed, the step did not and refill is not owed. The module refuses to pick for you. There are two ways out.

Supply elapsedRealtimeMs on the clock source and the ambiguity is settled from the host itself. That reader returns suspend inclusive elapsed time, which is what CLOCK_BOOTTIME counts and what /proc/uptime reports; procUptimeElapsedRealtime() is included for Linux. Both clocks advance with real time except while suspended, so the gap between them since the last reading is the suspended time, and whatever the reader still cannot account for is a clock step, definitively. In that case the suspended part is always credited and the stepped part never is, whatever the policy says, because there is nothing left to guess about. The reader is consulted only when the two cheap readings have already disagreed, so the hot path stays two clock reads and the syscall happens on the handful of requests where the answer is worth paying for.

Supply no reader and suspendPolicy decides, which is why it is required at construction and has no default. 'ignore' credits only measured monotonic time, which is right on a server with a disciplined clock and costs a laptop the refill it accrued with the lid shut. 'credit' credits the wall delta, which is right where suspends are routine and hands out a free refill when the jump was really a correction. 'refuse' throws, which is right where a wrong limiter is worse than an unavailable one.

Monotonic moved backwards. Refused, not clamped. A source that regresses is not a monotonic source, so every duration it produces after that point is wrong by an unknown amount, and a limiter that quietly carries on is a limiter whose rate is fiction.

The tolerance for calling a disagreement an event has a fixed part (250 ms) and a proportional part (500 ppm of the monotonic delta, the slew ceiling ntpd uses). A purely fixed budget calls a healthy chrony host an anomaly every time a request follows a ten minute idle gap.

The calendar half

Period boundaries come from Intl.DateTimeFormat, so the zone rules are the runtime's, not a table frozen into this package. Converting a local time back to an instant is not a function, though, and the two places it is not are the places quota periods break.

At a spring forward the requested local time never happens. A daily quota resetting at 02:30 in New York has no 02:30 on 2021-03-14. The boundary snaps to the instant the gap closes, found by bisecting the offset to the millisecond, and that day is 23.5 hours long. At a fall back the local time happens twice. 01:30 on 2021-11-07 in New York is both 05:30Z and 06:30Z. The first occurrence wins, deterministically, and that day is 25 hours long. Both cases are reported on the period as startKind.

A period is identified by its calendar slot rather than by its start instant, which is what keeps monthly billing on the 31st honest. February clamps back to the 28th, but the slot is still February, so the next boundary is March 31st. Derive the next boundary from the clamped instant instead and a single short month makes the anchor permanent.

Two more things the calendar half refuses:

A backwards step across a period boundary does not reopen the earlier period. Reopening it hands the tenant a second full month of allowance for time that has already been billed, and doing it twice hands them a third. The open period is held, the consumption already recorded stays recorded, and a period-regression anomaly is raised. Forward across a boundary the period does advance, even when the jump that carried it there was itself an anomaly, because once the wall clock says it is March the upstream service counting the same quota says so too.

A wall clock that reads before 2020 is refused when a quota is configured. A host with no battery backed clock boots at the epoch and only learns the date once the network is up. Filing consumption under the period "1970-01" and resetting it fifty six years later is not a useful answer, and the request that does it looks completely ordinary. Set earliestPlausibleWallMs to move the floor or to null to turn the check off.

Known limitations

A suspend that coincides with an equal and opposite wall clock step is invisible. The reconciliation triggers on the two readings disagreeing. Suspend for six hours while the clock is corrected six hours backwards and they agree, so no suspend inclusive reading is taken and the refill is not credited. The suspended time is not lost, it is claimed at the next divergence, but the requests in between see a bucket that is emptier than it should be.

retryAfterMs for a quota refusal is only as good as the wall clock. It is the distance to a calendar boundary, and if the host clock is corrected the boundary moves under it. retryAfterBasis is on the decision so callers can treat the two kinds of wait differently, but there is no way to make a countdown to midnight more trustworthy than the clock that defines midnight.

Node has no builtin suspend inclusive clock. procUptimeElapsedRealtime() is Linux only. On macOS and Windows you either write a reader over the platform call yourself or accept that suspendPolicy is a guess.

State is in process and not durable. Restarting the process resets the bucket to its initial tokens and the quota counter to zero for the period in progress. Persisting the counter is a different problem with a different set of races, and this module does not pretend to solve it.

periodFor is O(1) in the size of the jump, but countSkipped caps at 4. A decision after a clock jump reports that more than one period was skipped without saying exactly how many. Walking six hundred months of calendar arithmetic to produce an exact figure would land on whichever unlucky request came first after the correction.

Sub-day quota units are not supported and the spec refuses them. A rate below a day is a duration, which is what the token bucket already measures on a clock that cannot be stepped.

Test

npm install
npm test   # 128 tests: clock steps, suspends, DST gaps and repeats, period regression

The tests drive a manualClock whose monotonic, wall, and suspend inclusive components move independently, because nobody can suspend a CI runner or step its clock backwards from inside a test. Several of them run the four line naive bucket alongside this one on the same readings and assert it gets the wrong answer.

License

MIT

About

Rate limiter clock that refills on a monotonic source and resets quota periods on a wall clock calendar

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages