Public: anyone can read this; the crew can change it.
A test pins the rate limiter's sliding window: no double burst across a minute boundary, refused requests not counted, admin allowance and the off switch #6725
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/6713-a-test-pins-the-rate-limiter-s-sliding-w → main 1 commit fork
2 files
changed +230
−3
src/core/ratelimit.ts +5 −3
| @@ -62,9 +62,11 @@ export function limitsFromEnv( | ||
| 62 | 62 | const perMinute = positive(raw, DEFAULT_LIMIT); |
| 63 | 63 | return { |
| 64 | 64 | perMinute, |
| 65 | adminPerMinute: positive( | |
| 66 | env["FORGE_RATE_LIMIT_ADMIN"], | |
| 67 | Math.max(perMinute, DEFAULT_ADMIN_LIMIT), | |
| 65 | // An admin token is never held tighter than an ordinary one, whatever | |
| 66 | // FORGE_RATE_LIMIT_ADMIN says. | |
| 67 | adminPerMinute: Math.max( | |
| 68 | perMinute, | |
| 69 | positive(env["FORGE_RATE_LIMIT_ADMIN"], DEFAULT_ADMIN_LIMIT), | |
| 68 | 70 | ), |
| 69 | 71 | enabled: true, |
| 70 | 72 | }; |
test/ratelimit.test.ts added +225
| @@ -0,0 +1,225 @@ | ||
| 1 | /** | |
| 2 | * The rate limiter driven directly, with a fake clock. | |
| 3 | * | |
| 4 | * The HTTP tests in agents.test.ts see 429s; these pin the arithmetic under | |
| 5 | * them: the window slides (no double burst across a minute boundary), a | |
| 6 | * refusal is not counted, Retry-After and X-RateLimit-Reset point at the | |
| 7 | * moment the oldest hit leaves the window, and the environment parses the | |
| 8 | * way the module comment promises. | |
| 9 | */ | |
| 10 | import { describe, expect, test } from "bun:test"; | |
| 11 | import { | |
| 12 | limitsFromEnv, | |
| 13 | RateLimiter, | |
| 14 | type RateLimits, | |
| 15 | WINDOW_MS, | |
| 16 | } from "../src/core/ratelimit.ts"; | |
| 17 | ||
| 18 | /** A limiter on a clock the test moves by hand; `at` is milliseconds. */ | |
| 19 | function clocked(limits: Partial<RateLimits> = {}) { | |
| 20 | const clock = { at: 0 }; | |
| 21 | const limiter = new RateLimiter( | |
| 22 | { perMinute: 3, adminPerMinute: 30, enabled: true, ...limits }, | |
| 23 | () => clock.at, | |
| 24 | ); | |
| 25 | return { clock, limiter }; | |
| 26 | } | |
| 27 | ||
| 28 | describe("RateLimiter", () => { | |
| 29 | test("at the limit the next take is refused, pointing at when the oldest hit leaves", () => { | |
| 30 | const { clock, limiter } = clocked({ perMinute: 3 }); | |
| 31 | for (const at of [1_000, 2_000, 3_000]) { | |
| 32 | clock.at = at; | |
| 33 | expect(limiter.take("k", "ro").allowed).toBe(true); | |
| 34 | } | |
| 35 | clock.at = 10_500; | |
| 36 | const refused = limiter.take("k", "ro"); | |
| 37 | expect(refused.allowed).toBe(false); | |
| 38 | expect(refused.remaining).toBe(0); | |
| 39 | expect(refused.limit).toBe(3); | |
| 40 | // Oldest hit at 1 000 ms leaves at 1 000 + 60 000 = 61 000 ms. | |
| 41 | // Wait = 61 000 − 10 500 = 50 500 ms → ceil to 51 s. | |
| 42 | expect(refused.retryAfter).toBe(51); | |
| 43 | expect(refused.reset).toBe(61); | |
| 44 | ||
| 45 | clock.at = 60_999; | |
| 46 | expect(limiter.take("k", "ro").allowed).toBe(false); | |
| 47 | clock.at = 61_000; | |
| 48 | const back = limiter.take("k", "ro"); | |
| 49 | expect(back.allowed).toBe(true); | |
| 50 | // Hits at 2 000, 3 000, 61 000 are live: 3 of 3 used. | |
| 51 | expect(back.remaining).toBe(0); | |
| 52 | }); | |
| 53 | ||
| 54 | test("allowed takes count down remaining and reset a window from now", () => { | |
| 55 | const { clock, limiter } = clocked({ perMinute: 3 }); | |
| 56 | clock.at = 5_250; | |
| 57 | const first = limiter.take("k", "rw"); | |
| 58 | expect(first).toEqual({ | |
| 59 | allowed: true, | |
| 60 | limit: 3, | |
| 61 | remaining: 2, | |
| 62 | reset: Math.ceil((5_250 + WINDOW_MS) / 1000), | |
| 63 | retryAfter: 0, | |
| 64 | }); | |
| 65 | expect(limiter.take("k", "rw").remaining).toBe(1); | |
| 66 | expect(limiter.take("k", "rw").remaining).toBe(0); | |
| 67 | }); | |
| 68 | ||
| 69 | test("a burst at 59.9 s and another at 60.1 s: the second is refused", () => { | |
| 70 | const limit = 10; | |
| 71 | const { clock, limiter } = clocked({ perMinute: limit }); | |
| 72 | clock.at = 59_900; | |
| 73 | let first = 0; | |
| 74 | for (let i = 0; i < limit; i++) if (limiter.take("k", "ro").allowed) first++; | |
| 75 | expect(first).toBe(limit); | |
| 76 | ||
| 77 | // A fixed per-minute bucket starts afresh at 60 000 ms and would let all | |
| 78 | // ten through: twenty requests in 200 ms. The sliding window still holds | |
| 79 | // the ten from 59.9 s until 119.9 s. | |
| 80 | clock.at = 60_100; | |
| 81 | let second = 0; | |
| 82 | for (let i = 0; i < limit; i++) if (limiter.take("k", "ro").allowed) second++; | |
| 83 | expect(second).toBe(0); | |
| 84 | ||
| 85 | clock.at = 119_899; | |
| 86 | expect(limiter.take("k", "ro").allowed).toBe(false); | |
| 87 | clock.at = 119_900; | |
| 88 | expect(limiter.take("k", "ro").allowed).toBe(true); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a burst spread across the boundary is mostly refused on the far side", () => { | |
| 92 | const limit = 10; | |
| 93 | const { clock, limiter } = clocked({ perMinute: limit }); | |
| 94 | // Eight at 59.9 s leave two for the next minute, not ten. | |
| 95 | clock.at = 59_900; | |
| 96 | for (let i = 0; i < 8; i++) expect(limiter.take("k", "ro").allowed).toBe(true); | |
| 97 | clock.at = 60_100; | |
| 98 | let allowed = 0; | |
| 99 | for (let i = 0; i < limit; i++) if (limiter.take("k", "ro").allowed) allowed++; | |
| 100 | expect(allowed).toBe(2); | |
| 101 | }); | |
| 102 | ||
| 103 | test("hammering while refused does not push the window forward", () => { | |
| 104 | const { clock, limiter } = clocked({ perMinute: 2 }); | |
| 105 | clock.at = 0; | |
| 106 | limiter.take("k", "ro"); | |
| 107 | clock.at = 1_000; | |
| 108 | limiter.take("k", "ro"); | |
| 109 | ||
| 110 | clock.at = 30_000; | |
| 111 | const promised = limiter.take("k", "ro"); | |
| 112 | expect(promised.allowed).toBe(false); | |
| 113 | expect(promised.retryAfter).toBe(30); | |
| 114 | expect(promised.reset).toBe(60); | |
| 115 | ||
| 116 | // A poll loop with no sleep in it, every 10 ms until the promised time. | |
| 117 | for (clock.at = 30_000; clock.at < 60_000; clock.at += 10) { | |
| 118 | const verdict = limiter.take("k", "ro"); | |
| 119 | expect(verdict.allowed).toBe(false); | |
| 120 | expect(verdict.reset).toBe(60); | |
| 121 | } | |
| 122 | ||
| 123 | clock.at = promised.reset * 1000; | |
| 124 | expect(limiter.take("k", "ro").allowed).toBe(true); | |
| 125 | }); | |
| 126 | ||
| 127 | test("admin scope uses adminPerMinute; ro and rw share one budget", () => { | |
| 128 | const { limiter } = clocked({ perMinute: 2, adminPerMinute: 5 }); | |
| 129 | ||
| 130 | expect(limiter.take("token", "ro").allowed).toBe(true); | |
| 131 | expect(limiter.take("token", "rw").allowed).toBe(true); | |
| 132 | const rw = limiter.take("token", "rw"); | |
| 133 | expect(rw.allowed).toBe(false); | |
| 134 | expect(rw.limit).toBe(2); | |
| 135 | expect(limiter.take("token", "ro").allowed).toBe(false); | |
| 136 | ||
| 137 | for (let i = 0; i < 5; i++) { | |
| 138 | const verdict = limiter.take("admin", "admin"); | |
| 139 | expect(verdict.allowed).toBe(true); | |
| 140 | expect(verdict.limit).toBe(5); | |
| 141 | expect(verdict.remaining).toBe(4 - i); | |
| 142 | } | |
| 143 | expect(limiter.take("admin", "admin").allowed).toBe(false); | |
| 144 | }); | |
| 145 | ||
| 146 | test("keys are counted apart", () => { | |
| 147 | const { limiter } = clocked({ perMinute: 1 }); | |
| 148 | expect(limiter.take("a", "ro").allowed).toBe(true); | |
| 149 | expect(limiter.take("a", "ro").allowed).toBe(false); | |
| 150 | expect(limiter.take("b", "ro").allowed).toBe(true); | |
| 151 | }); | |
| 152 | ||
| 153 | test("disabled, every take is allowed and nothing is counted", () => { | |
| 154 | const { limiter } = clocked({ perMinute: 0, adminPerMinute: 0, enabled: false }); | |
| 155 | for (let i = 0; i < 1_000; i++) expect(limiter.take("k", "rw").allowed).toBe(true); | |
| 156 | expect(limiter.size).toBe(0); | |
| 157 | expect(limiter.enabled).toBe(false); | |
| 158 | }); | |
| 159 | ||
| 160 | test("setLimits keeps the counted hits", () => { | |
| 161 | const { clock, limiter } = clocked({ perMinute: 5 }); | |
| 162 | clock.at = 1_000; | |
| 163 | for (let i = 0; i < 3; i++) limiter.take("k", "ro"); | |
| 164 | ||
| 165 | limiter.setLimits({ perMinute: 3, adminPerMinute: 30, enabled: true }); | |
| 166 | const tightened = limiter.take("k", "ro"); | |
| 167 | expect(tightened.allowed).toBe(false); | |
| 168 | expect(tightened.limit).toBe(3); | |
| 169 | ||
| 170 | limiter.setLimits({ perMinute: 10, adminPerMinute: 30, enabled: true }); | |
| 171 | const loosened = limiter.take("k", "ro"); | |
| 172 | expect(loosened.allowed).toBe(true); | |
| 173 | // Three earlier hits plus this one: 10 − 4. | |
| 174 | expect(loosened.remaining).toBe(6); | |
| 175 | }); | |
| 176 | }); | |
| 177 | ||
| 178 | describe("limitsFromEnv", () => { | |
| 179 | test("unset gives 600 ordinary and 6 000 admin", () => { | |
| 180 | expect(limitsFromEnv({})).toEqual({ | |
| 181 | perMinute: 600, | |
| 182 | adminPerMinute: 6_000, | |
| 183 | enabled: true, | |
| 184 | }); | |
| 185 | }); | |
| 186 | ||
| 187 | test("FORGE_RATE_LIMIT=0 turns it off", () => { | |
| 188 | expect(limitsFromEnv({ FORGE_RATE_LIMIT: "0" }).enabled).toBe(false); | |
| 189 | expect( | |
| 190 | limitsFromEnv({ FORGE_RATE_LIMIT: "0", FORGE_RATE_LIMIT_ADMIN: "50" }).enabled, | |
| 191 | ).toBe(false); | |
| 192 | }); | |
| 193 | ||
| 194 | test("non-numeric and negative values fall back to 600", () => { | |
| 195 | for (const raw of ["lots", "-5", "-0.5", "NaN", "Infinity"]) { | |
| 196 | const limits = limitsFromEnv({ FORGE_RATE_LIMIT: raw }); | |
| 197 | expect(limits.enabled).toBe(true); | |
| 198 | expect(limits.perMinute).toBe(600); | |
| 199 | } | |
| 200 | }); | |
| 201 | ||
| 202 | test("a fractional limit is floored", () => { | |
| 203 | expect(limitsFromEnv({ FORGE_RATE_LIMIT: "12.9" }).perMinute).toBe(12); | |
| 204 | }); | |
| 205 | ||
| 206 | test("the admin limit is never below the ordinary one", () => { | |
| 207 | // Raising the ordinary limit past the admin default lifts admin with it. | |
| 208 | expect(limitsFromEnv({ FORGE_RATE_LIMIT: "10000" }).adminPerMinute).toBe(10_000); | |
| 209 | // An explicit admin limit below the ordinary one is lifted to it. | |
| 210 | expect( | |
| 211 | limitsFromEnv({ FORGE_RATE_LIMIT: "10000", FORGE_RATE_LIMIT_ADMIN: "100" }) | |
| 212 | .adminPerMinute, | |
| 213 | ).toBe(10_000); | |
| 214 | // A bad admin value falls back to the admin default. | |
| 215 | expect( | |
| 216 | limitsFromEnv({ FORGE_RATE_LIMIT: "5", FORGE_RATE_LIMIT_ADMIN: "-1" }) | |
| 217 | .adminPerMinute, | |
| 218 | ).toBe(6_000); | |
| 219 | // An explicit admin limit above the ordinary one is kept. | |
| 220 | expect( | |
| 221 | limitsFromEnv({ FORGE_RATE_LIMIT: "5", FORGE_RATE_LIMIT_ADMIN: "50" }) | |
| 222 | .adminPerMinute, | |
| 223 | ).toBe(50); | |
| 224 | }); | |
| 225 | }); |
new above
offline