src/ration.ts merged in cf64fd3 with an exact retryAfterMs, and nothing calls it: src/server.ts still rations with the older allow() in src/http.ts, and a refused write gets too many requests with no retry-after and no way for a script to know when to come back.
Two limiters in one process is one too many, and the one in use is the one that cannot answer the only question a caller has.
Done looks like
[ ] src/server.ts rations writes through take(key, PERSON_WRITES, now) from src/ration.ts. The key is unchanged in shape — user:<id> for a signed-in person, ip:<address> for a stranger — and PERSON_WRITES keeps the numbers the app ships today (burst 20, 2/s), so this changes no policy, only who computes it.
[ ] A refusal carries retry-after, in whole seconds, rounded up from retryAfterMs (a retry-after: 0 invites an instant retry that must fail; the minimum on the wire is 1).
[ ] The refusal's body follows the door it came to: POST /api/* and POST /mcp answer JSON { "error": "too_many_requests", "message": ... , "retry_after_ms": <exact> } so a client can wait precisely; anything else keeps the plain-text body it has now. Both carry the security floor from src/http.ts.
[ ] allow(), RATE and buckets are deleted from src/http.ts, and its module comment stops claiming a rate limit. Nothing else in the repository imports them (checked: only src/server.ts does).
[ ] src/server.test.ts proves it end to end with a clock it controls: the 21st write in a burst is 429, its retry-after is present and ≥ 1, and a request made after exactly retry_after_ms succeeds while one made a millisecond earlier does not. src/ration.test.ts already proves the arithmetic; this test proves the door wired it up.
[ ] README.md's table row for src/http.ts drops "rate limit" and a row for src/ration.ts says what it is.
Issue #8 (the signed door) also plans to swap the doors over to src/ration.ts and add APP_EVENTS for apps. This issue does the person half now and leaves APP_EVENTS entirely to #8; #8 now waits on this so the swap happens once, not twice.
take() mutates its bucket on success only — a test that calls it to "check" spends a token. Assert through the server, not by calling take twice.
Rounding retry-after up is the safe direction; rounding down produces a retry that is refused again and looks like a broken limiter.
Suggested
Torque — the value of this is the arithmetic being exactly right on the wire, and src/ration.ts is already theirs.
src/ration.tsmerged incf64fd3with an exactretryAfterMs, and nothing calls it:src/server.tsstill rations with the olderallow()insrc/http.ts, and a refused write getstoo many requestswith noretry-afterand no way for a script to know when to come back.Two limiters in one process is one too many, and the one in use is the one that cannot answer the only question a caller has.
Done looks like
src/server.tsrations writes throughtake(key, PERSON_WRITES, now)fromsrc/ration.ts. The key is unchanged in shape —user:<id>for a signed-in person,ip:<address>for a stranger — andPERSON_WRITESkeeps the numbers the app ships today (burst 20, 2/s), so this changes no policy, only who computes it.retry-after, in whole seconds, rounded up fromretryAfterMs(aretry-after: 0invites an instant retry that must fail; the minimum on the wire is 1).POST /api/*andPOST /mcpanswer JSON{ "error": "too_many_requests", "message": ... , "retry_after_ms": <exact> }so a client can wait precisely; anything else keeps the plain-text body it has now. Both carry the security floor fromsrc/http.ts.allow(),RATEandbucketsare deleted fromsrc/http.ts, and its module comment stops claiming a rate limit. Nothing else in the repository imports them (checked: onlysrc/server.tsdoes).src/server.test.tsproves it end to end with a clock it controls: the 21st write in a burst is 429, itsretry-afteris present and ≥ 1, and a request made after exactlyretry_after_mssucceeds while one made a millisecond earlier does not.src/ration.test.tsalready proves the arithmetic; this test proves the door wired it up.README.md's table row forsrc/http.tsdrops "rate limit" and a row forsrc/ration.tssays what it is.Files
src/server.ts,src/http.ts,src/server.test.ts,README.md.Risks
src/ration.tsand addAPP_EVENTSfor apps. This issue does the person half now and leavesAPP_EVENTSentirely to #8; #8 now waits on this so the swap happens once, not twice.take()mutates its bucket on success only — a test that calls it to "check" spends a token. Assert through the server, not by callingtaketwice.retry-afterup is the safe direction; rounding down produces a retry that is refused again and looks like a broken limiter.Suggested
Torque — the value of this is the arithmetic being exactly right on the wire, and
src/ration.tsis already theirs.