An app that posts in a loop should be slowed, not guessed at — and the arithmetic that does it is exact, deterministic and testable without a server. Buildable today: it is one new file that changes no behaviour.
Done looks like
[ ] src/ration.ts exports take(key, now?, limit?) returning { ok: true, remaining } or { ok: false, retryAfterMs }. It is a token bucket: burst tokens, refilled at rate per second, fractions carried, never more than burst in hand.
[ ] now is a parameter and there are no timers: a test moves time by hand and asserts the refill exactly. A refusal's retryAfterMs is the moment a retry actually succeeds — a test takes the refusal, advances by exactly that much, and succeeds, and advances by one millisecond less and fails.
[ ] Two named limits live here and nowhere else: one for a person's writes (what allow() in src/http.ts rations today) and one for an app posting events — generous for a burst of a hundred, steady at a few a second. Both are written as constants with a sentence saying why those numbers.
[ ] Memory is bounded: at most 10,000 keys, the least recently used dropped when it is full, and a dropped key comes back with a full bucket — a ration is not a debt. A test pushes past the cap and asserts the map stops growing.
[ ] The module returns milliseconds and knows nothing about HTTP; rounding up to whole seconds for a retry-after header is the door's job. No Request, no Who, no database: keys are strings.
[ ] src/ration.test.ts also covers one key never spending another's tokens, a burst drained then refused, and half-second refills accumulating rather than truncating to zero.
[ ] src/http.ts is left alone: allow() keeps working and #8 swaps the doors over, so this pull adds a file and a test and changes nothing that runs.
Files:src/ration.ts, src/ration.test.ts
Risks: an in-memory ration resets on restart and is per-process — say so in the header comment, and say why that is the right trade for a forge app (the alternative is a write to SQLite on every request). Keep the exported surface small; #8 will call it from one place.
Suggested: Torque — exact arithmetic with the numbers stated, a test that matches a hand calculation.
An app that posts in a loop should be slowed, not guessed at — and the arithmetic that does it is exact, deterministic and testable without a server. Buildable today: it is one new file that changes no behaviour.
Done looks like
src/ration.tsexportstake(key, now?, limit?)returning{ ok: true, remaining }or{ ok: false, retryAfterMs }. It is a token bucket:bursttokens, refilled atrateper second, fractions carried, never more thanburstin hand.nowis a parameter and there are no timers: a test moves time by hand and asserts the refill exactly. A refusal'sretryAfterMsis the moment a retry actually succeeds — a test takes the refusal, advances by exactly that much, and succeeds, and advances by one millisecond less and fails.allow()insrc/http.tsrations today) and one for an app posting events — generous for a burst of a hundred, steady at a few a second. Both are written as constants with a sentence saying why those numbers.retry-afterheader is the door's job. NoRequest, noWho, no database: keys are strings.src/ration.test.tsalso covers one key never spending another's tokens, a burst drained then refused, and half-second refills accumulating rather than truncating to zero.src/http.tsis left alone:allow()keeps working and #8 swaps the doors over, so this pull adds a file and a test and changes nothing that runs.Files:
src/ration.ts,src/ration.test.tsRisks: an in-memory ration resets on restart and is per-process — say so in the header comment, and say why that is the right trade for a forge app (the alternative is a write to SQLite on every request). Keep the exported surface small; #8 will call it from one place.
Suggested: Torque — exact arithmetic with the numbers stated, a test that matches a hand calculation.