Public: anyone can read this; the crew can change it.
A ration a machine can read: src/ration.ts, a bucket per key and a retry that is true #31
Check · Review · Land · Live done — Landed and live in production.
torque/relay:torque/24-a-ration-a-machine-can-read-src-ration-t → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +236
−0
src/ration.test.ts added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { allow } from "./http.ts"; | |
| 3 | import { | |
| 4 | APP_EVENTS, | |
| 5 | keyCount, | |
| 6 | MAX_KEYS, | |
| 7 | PERSON_WRITES, | |
| 8 | type Ration, | |
| 9 | take, | |
| 10 | } from "./ration.ts"; | |
| 11 | ||
| 12 | // The clock is a parameter, so every test moves time by hand. Keys are | |
| 13 | // unique per test because the buckets are one map for the process. | |
| 14 | const T0 = 1_700_000_000_000; | |
| 15 | ||
| 16 | const drain = (key: string, now: number, limit = PERSON_WRITES): Ration => { | |
| 17 | let last: Ration = { ok: true, remaining: limit.burst }; | |
| 18 | for (let i = 0; i < limit.burst; i++) last = take(key, now, limit); | |
| 19 | return last; | |
| 20 | }; | |
| 21 | ||
| 22 | test("a full bucket hands out exactly burst tokens, counting down", () => { | |
| 23 | // 20 tokens in hand: the first take leaves 19, the twentieth leaves 0. | |
| 24 | expect(take("count", T0)).toEqual({ ok: true, remaining: 19 }); | |
| 25 | expect(take("count", T0)).toEqual({ ok: true, remaining: 18 }); | |
| 26 | for (let i = 0; i < 17; i++) take("count", T0); | |
| 27 | expect(take("count", T0)).toEqual({ ok: true, remaining: 0 }); | |
| 28 | // 2 per second is one token every 500ms, and none have been earned yet. | |
| 29 | expect(take("count", T0)).toEqual({ ok: false, retryAfterMs: 500 }); | |
| 30 | }); | |
| 31 | ||
| 32 | test("the retry is the moment a retry actually works", () => { | |
| 33 | expect(drain("retry", T0)).toEqual({ ok: true, remaining: 0 }); | |
| 34 | expect(take("retry", T0)).toEqual({ ok: false, retryAfterMs: 500 }); | |
| 35 | // One millisecond short of the promise, and the promise shrinks to 1ms. | |
| 36 | expect(take("retry", T0 + 499)).toEqual({ ok: false, retryAfterMs: 1 }); | |
| 37 | // Exactly the promise, and it is kept. | |
| 38 | expect(take("retry", T0 + 500)).toEqual({ ok: true, remaining: 0 }); | |
| 39 | }); | |
| 40 | ||
| 41 | test("a refusal spends nothing: waiting the first answer is still enough", () => { | |
| 42 | drain("patient", T0); | |
| 43 | expect(take("patient", T0)).toEqual({ ok: false, retryAfterMs: 500 }); | |
| 44 | // Hammering while refused must not push the retry further out. | |
| 45 | for (let i = 0; i < 50; i++) take("patient", T0 + 100); | |
| 46 | expect(take("patient", T0 + 400)).toEqual({ ok: false, retryAfterMs: 100 }); | |
| 47 | expect(take("patient", T0 + 500)).toEqual({ ok: true, remaining: 0 }); | |
| 48 | }); | |
| 49 | ||
| 50 | test("half-second refills accumulate rather than truncate to zero", () => { | |
| 51 | drain("halves", T0); | |
| 52 | // Each of these earns half a token. A bucket that truncated would never | |
| 53 | // reach one no matter how many times it was asked. | |
| 54 | expect(take("halves", T0 + 250)).toEqual({ ok: false, retryAfterMs: 250 }); | |
| 55 | expect(take("halves", T0 + 375)).toEqual({ ok: false, retryAfterMs: 125 }); | |
| 56 | expect(take("halves", T0 + 499)).toEqual({ ok: false, retryAfterMs: 1 }); | |
| 57 | expect(take("halves", T0 + 500)).toEqual({ ok: true, remaining: 0 }); | |
| 58 | }); | |
| 59 | ||
| 60 | test("a drained bucket refills at the rate, by hand", () => { | |
| 61 | drain("refill", T0); | |
| 62 | // 1.5s at 2 per second is 3 tokens. One is spent here, so 2 remain. | |
| 63 | expect(take("refill", T0 + 1_500)).toEqual({ ok: true, remaining: 2 }); | |
| 64 | expect(take("refill", T0 + 1_500)).toEqual({ ok: true, remaining: 1 }); | |
| 65 | expect(take("refill", T0 + 1_500)).toEqual({ ok: true, remaining: 0 }); | |
| 66 | expect(take("refill", T0 + 1_500)).toEqual({ ok: false, retryAfterMs: 500 }); | |
| 67 | }); | |
| 68 | ||
| 69 | test("an idle bucket never holds more than burst", () => { | |
| 70 | drain("idle", T0); | |
| 71 | // A day of quiet earns 172,800 tokens and the bucket still holds 20. | |
| 72 | expect(take("idle", T0 + 86_400_000)).toEqual({ ok: true, remaining: 19 }); | |
| 73 | for (let i = 0; i < 19; i++) take("idle", T0 + 86_400_000); | |
| 74 | expect(take("idle", T0 + 86_400_000)).toEqual({ | |
| 75 | ok: false, | |
| 76 | retryAfterMs: 500, | |
| 77 | }); | |
| 78 | }); | |
| 79 | ||
| 80 | test("one key never spends another's tokens", () => { | |
| 81 | drain("mine", T0); | |
| 82 | expect(take("mine", T0)).toEqual({ ok: false, retryAfterMs: 500 }); | |
| 83 | expect(take("yours", T0)).toEqual({ ok: true, remaining: 19 }); | |
| 84 | }); | |
| 85 | ||
| 86 | test("an app's ration is a hundred at once, then five a second", () => { | |
| 87 | expect(take("app", T0, APP_EVENTS)).toEqual({ ok: true, remaining: 99 }); | |
| 88 | for (let i = 0; i < 99; i++) take("app", T0, APP_EVENTS); | |
| 89 | // 5 per second is one token every 200ms. | |
| 90 | expect(take("app", T0, APP_EVENTS)).toEqual({ ok: false, retryAfterMs: 200 }); | |
| 91 | expect(take("app", T0 + 199, APP_EVENTS)).toEqual({ | |
| 92 | ok: false, | |
| 93 | retryAfterMs: 1, | |
| 94 | }); | |
| 95 | expect(take("app", T0 + 1_000, APP_EVENTS)).toEqual({ | |
| 96 | ok: true, | |
| 97 | remaining: 4, | |
| 98 | }); | |
| 99 | }); | |
| 100 | ||
| 101 | test("a person's ration is the one src/http.ts applies today", () => { | |
| 102 | // #8 swaps the doors over; this is the check that nothing changes when | |
| 103 | // it does. `allow()` keeps its own map, so the keys are separate. | |
| 104 | let allowed = 0; | |
| 105 | for (let i = 0; i < 25; i++) if (allow("parity-allow", T0)) allowed++; | |
| 106 | let taken = 0; | |
| 107 | for (let i = 0; i < 25; i++) if (take("parity-take", T0).ok) taken++; | |
| 108 | expect(taken).toBe(allowed); | |
| 109 | expect(taken).toBe(PERSON_WRITES.burst); | |
| 110 | expect(allow("parity-allow", T0 + 500)).toBe(true); | |
| 111 | expect(take("parity-take", T0 + 500).ok).toBe(true); | |
| 112 | }); | |
| 113 | ||
| 114 | // Last: this one fills the map, which drops every key the tests above used. | |
| 115 | test("the map stops growing, and a dropped key comes back full", () => { | |
| 116 | drain("victim", T0); | |
| 117 | expect(take("victim", T0)).toEqual({ ok: false, retryAfterMs: 500 }); | |
| 118 | for (let i = 0; i <= MAX_KEYS; i++) take(`flood-${i}`, T0); | |
| 119 | expect(keyCount()).toBe(MAX_KEYS); | |
| 120 | // The oldest key went, and it comes back with a full bucket: the ration | |
| 121 | // it was refused is not a debt it still owes. | |
| 122 | expect(take("victim", T0)).toEqual({ ok: true, remaining: 19 }); | |
| 123 | for (let i = 0; i <= MAX_KEYS; i++) take(`flood-more-${i}`, T0); | |
| 124 | expect(keyCount()).toBe(MAX_KEYS); | |
| 125 | }); |
src/ration.ts added +111
| @@ -0,0 +1,111 @@ | ||
| 1 | /** | |
| 2 | * How much a caller may do and how long it must wait. One token bucket per | |
| 3 | * key: `burst` tokens in hand, refilled at `perSecond`, fractions carried, | |
| 4 | * never more than `burst`. The clock is a parameter, the answer is in | |
| 5 | * milliseconds, and nothing here knows what a request is — rounding a | |
| 6 | * refusal up to whole seconds for a `retry-after` header is the door's job. | |
| 7 | * | |
| 8 | * The bucket is kept as one number, the time the schedule has run up to | |
| 9 | * (`tat`, the theoretical arrival time of the next token spent), because | |
| 10 | * that makes the retry exact rather than nearly right: | |
| 11 | * | |
| 12 | * perToken = 1000 / perSecond ms of refill one token costs | |
| 13 | * ahead = (burst - 1) * perToken how far past `now` the schedule may run | |
| 14 | * tat = max(stored, now) an idle bucket fills, and stops at full | |
| 15 | * take when now >= tat - ahead, and the schedule becomes tat + perToken | |
| 16 | * refuse otherwise, retryAfterMs = ceil((tat - ahead) - now) | |
| 17 | * | |
| 18 | * That is the same arithmetic as tokens-and-a-timestamp (tokens in hand are | |
| 19 | * `(now + ahead - tat) / perToken`), with one difference that matters: a | |
| 20 | * refusal stores nothing, so the retry compares against the very same | |
| 21 | * number it was measured from. Advance a refused caller by exactly | |
| 22 | * `retryAfterMs` and it succeeds; by one millisecond less and it does not. | |
| 23 | * | |
| 24 | * Memory is bounded at MAX_KEYS, the least recently used dropped first. A | |
| 25 | * dropped key comes back with a full bucket: a ration is not a debt. | |
| 26 | * | |
| 27 | * This lives in memory, so it resets on restart and counts per process. | |
| 28 | * That is the right trade here — the alternative is a SQLite write on every | |
| 29 | * request, which costs more than the flood it would stop, and this app is | |
| 30 | * one process on one machine. A restart hands out one extra burst; a | |
| 31 | * caller in a loop is still held to `perSecond` for as long as it loops. | |
| 32 | * | |
| 33 | * Keys are opaque strings and never collide: the caller supplies an app id | |
| 34 | * or an address it has already validated. Two keys are two buckets. | |
| 35 | */ | |
| 36 | ||
| 37 | export type Limit = { | |
| 38 | /** Tokens in hand when the bucket is full. At least 1. */ | |
| 39 | burst: number; | |
| 40 | /** Tokens added per second. Greater than 0. */ | |
| 41 | perSecond: number; | |
| 42 | }; | |
| 43 | ||
| 44 | export type Ration = | |
| 45 | | { ok: true; remaining: number } | |
| 46 | | { ok: false; retryAfterMs: number }; | |
| 47 | ||
| 48 | /** | |
| 49 | * A person's writes, the ration `allow()` in `src/http.ts` applies today: | |
| 50 | * twenty in hand is more than a person types in a sitting, and two a second | |
| 51 | * is faster than a form can be filled, so the only caller that ever feels | |
| 52 | * it is a script wearing a person's cookie. | |
| 53 | */ | |
| 54 | export const PERSON_WRITES: Limit = { burst: 20, perSecond: 2 }; | |
| 55 | ||
| 56 | /** | |
| 57 | * An app posting events. A deploy or a batch job fires a hundred at once | |
| 58 | * and should not be punished for it, so the burst is a hundred; sustained, | |
| 59 | * five a second is far more than a real app emits and still leaves the | |
| 60 | * timeline readable. An app over this is looping, not working. | |
| 61 | */ | |
| 62 | export const APP_EVENTS: Limit = { burst: 100, perSecond: 5 }; | |
| 63 | ||
| 64 | /** At most this many keys are remembered at once. */ | |
| 65 | export const MAX_KEYS = 10_000; | |
| 66 | ||
| 67 | // key -> tat, the time the schedule has run up to. Map iterates in insertion | |
| 68 | // order, so re-inserting on every touch keeps the least recently used first. | |
| 69 | const buckets = new Map<string, number>(); | |
| 70 | ||
| 71 | /** Spends one token of `key`'s ration, or says when a retry will work. */ | |
| 72 | export function take( | |
| 73 | key: string, | |
| 74 | now = Date.now(), | |
| 75 | limit: Limit = PERSON_WRITES, | |
| 76 | ): Ration { | |
| 77 | const perToken = 1000 / limit.perSecond; | |
| 78 | const ahead = (limit.burst - 1) * perToken; | |
| 79 | const stored = buckets.get(key); | |
| 80 | // An idle bucket fills up to `burst` and no further: the schedule never | |
| 81 | // runs behind the clock. | |
| 82 | const tat = stored === undefined || stored < now ? now : stored; | |
| 83 | const earliest = tat - ahead; | |
| 84 | if (now < earliest) { | |
| 85 | // Nothing is spent and nothing is stored — only the key's place in the | |
| 86 | // queue moves, so a busy key is not the one dropped. | |
| 87 | touch(key, tat); | |
| 88 | return { ok: false, retryAfterMs: Math.ceil(earliest - now) }; | |
| 89 | } | |
| 90 | touch(key, tat + perToken); | |
| 91 | // Tokens still in hand after this one. The divide is floating point and a | |
| 92 | // whole token can land a hair under it, so the last bit does not hide a | |
| 93 | // token the caller holds. | |
| 94 | return { | |
| 95 | ok: true, | |
| 96 | remaining: Math.max(0, Math.floor((now + ahead - tat) / perToken + 1e-9)), | |
| 97 | }; | |
| 98 | } | |
| 99 | ||
| 100 | /** How many keys are held right now. For tests and for a status line. */ | |
| 101 | export const keyCount = (): number => buckets.size; | |
| 102 | ||
| 103 | function touch(key: string, tat: number): void { | |
| 104 | buckets.delete(key); | |
| 105 | buckets.set(key, tat); | |
| 106 | while (buckets.size > MAX_KEYS) { | |
| 107 | const oldest = buckets.keys().next(); | |
| 108 | if (oldest.done === true) break; | |
| 109 | buckets.delete(oldest.value); | |
| 110 | } | |
| 111 | } |
High: this
Math.ceilis the pull's headline property and no test would fail if it wereMath.floor. Every refusal the tests take is at 2/s or 5/s, whereearliest - nowis already whole (500ms, 200ms, 334... never), so ceil and floor agree and the exactness is accidental. At{ burst: 3, perSecond: 3 }the true wait is 333.33ms:ceilpromises 334 and a retry works,floorpromises 333 and the caller is refused a second time — a polite client in a retry loop then looks like a flood. Fix: add a refusal case with a fractional token (drain{ burst: 3, perSecond: 3 }, assertretryAfterMsis 334, then assert+333refuses and+334succeeds). Keep theceil; the code is right, it just is not held here.