I'm using the writing-plans skill to create the implementation plan. I can't write files here, so the plan is printed below. Its intended path is docs/superpowers/plans/2026-10-05-rate-limiting.md.
Rate Limiting Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Limit each client IP to 100 requests per minute on the Express API, answering excess requests with HTTP 429 and a Retry-After header.
Architecture: A small middleware factory in src/rateLimiter.js keeps a per-IP fixed-window counter in an in-memory Map. A separate src/config.js reads the limit and window from environment variables. src/app.js mounts the middleware before all routes.
Tech Stack: Node.js, Express, Jest, supertest. Supertest is added as a devDependency if it is not already present.
Spec: The fictional spec was given inline in the request. No spec file exists.
Global Constraints
- Default limit is 100 requests per IP per window.
- Default window is 60 seconds (1 minute).
- A request over the limit gets HTTP 429 with a
Retry-Afterheader. - The limit and window are configurable through environment variables.
- The store is in-memory, with no external dependencies such as Redis.
- Tests use Jest.
Review Focus
- Window expiry: after the window passes, the same IP is allowed again and its counter restarts. Pinned in Task 2.
- Independent IPs: one IP exhausting its limit must not block another IP. Pinned in Task 2.
- Invalid env values (
RATE_LIMIT_MAX=abc,0, negative, empty): fall back to the defaults instead of producingNaNand silently disabling or blocking everything. Pinned in Task 1. Retry-Afteraccuracy: the value is a positive integer in seconds, the time remaining in the current window, and never0or fractional. Pinned in Task 2.- Unbounded memory growth: entries for IPs that stop sending requests are evicted after their window expires. Pinned in Task 3.
Task 1: Configuration module
Files:
- Create:
src/config.js - Test:
tests/config.test.js
Interfaces:
-
Consumes:
process.env.RATE_LIMIT_MAXandprocess.env.RATE_LIMIT_WINDOW_MS. -
Produces:
getRateLimitConfig(env = process.env): { max: number, windowMs: number }. It defaults to{ max: 100, windowMs: 60000 }. -
[ ] Step 1: Write the failing tests in
tests/config.test.js:defaults to 100 requests per 60000ms:getRateLimitConfig({})returns{ max: 100, windowMs: 60000 }.reads RATE_LIMIT_MAX and RATE_LIMIT_WINDOW_MS:getRateLimitConfig({ RATE_LIMIT_MAX: '5', RATE_LIMIT_WINDOW_MS: '1000' })returns{ max: 5, windowMs: 1000 }.falls back to defaults for invalid values: for each of'abc','0','-3',''and'1.5', both variables fall back to their defaults.
-
[ ] Step 2: Run
npx jest tests/config.test.js. Expected: FAIL with "Cannot find module '../src/config'". -
[ ] Step 3: Implement
getRateLimitConfig(env = process.env)insrc/config.js. A value counts as valid only if it parses to a positive integer. Anything else falls back to the default. -
[ ] Step 4: Run
npx jest tests/config.test.js. Expected: PASS. -
[ ] Step 5: Commit
git add src/config.js tests/config.test.js git commit -m "feat: add rate limit config from env"
Task 2: Rate limiter middleware
Files:
- Create:
src/rateLimiter.js - Test:
tests/rateLimiter.test.js
Interfaces:
-
Consumes:
{ max, windowMs }as produced bygetRateLimitConfig(Task 1). -
Produces:
createRateLimiter({ max: number, windowMs: number, now?: () => number }): express.RequestHandler. Thenowoption defaults toDate.nowand exists so tests can control time. The middleware keys onreq.ip. It callsnext()while the count is at or belowmax. Otherwise it setsRetry-AftertoMath.ceil(msRemaining / 1000)and responds429with{ error: 'Too Many Requests' }. -
[ ] Step 1: Write the failing tests in
tests/rateLimiter.test.js. Use a bare Express app withcreateRateLimiter({ max: 3, windowMs: 1000, now }), aGET /pingroute, and supertest.nowis a fake clock variable the test advances.allows requests up to the limit: 3 requests return 200.returns 429 with Retry-After once the limit is exceeded: the 4th request returns 429, andNumber(res.headers['retry-after'])is an integer ≥ 1 and ≤ 1.Retry-After reflects time remaining in window: with the clock advanced 400ms, the 429 carriesRetry-After: 1. This isceil(0.6), so it is never0.resets after the window elapses: advance the clock by 1000ms, and the next request returns 200.tracks IPs independently: exhaust the limit withX-Forwarded-For: 1.1.1.1(the app setstrust proxyin the test), and a request from2.2.2.2still returns 200.
-
[ ] Step 2: Run
npx jest tests/rateLimiter.test.js. Expected: FAIL with "Cannot find module '../src/rateLimiter'". -
[ ] Step 3: Implement
createRateLimiterinsrc/rateLimiter.js. Use a closure-scopedMap<ip, { count, resetAt }>with a fixed window. A new window starts whennow() >= resetAt. -
[ ] Step 4: Run
npx jest tests/rateLimiter.test.js. Expected: PASS. -
[ ] Step 5: Commit
git add src/rateLimiter.js tests/rateLimiter.test.js git commit -m "feat: add in-memory per-IP rate limiter middleware"
Task 3: Expired-entry eviction
Files:
- Modify:
src/rateLimiter.js - Test:
tests/rateLimiter.test.js
Interfaces:
-
Consumes:
createRateLimiterfrom Task 2. -
Produces: the returned middleware gains a
middleware.size(): numbermethod that returns the number of tracked IPs. Expired entries are swept by an interval timer that isunref()'d. The middleware also getsmiddleware.stop(): void, which clears the timer so Jest can exit cleanly. -
[ ] Step 1: Write the failing test
evicts expired entries. Make requests from 3 IPs and assertsize()is 3. Advance the fake clock pastwindowMs, trigger a sweep, and assertsize()is 0. Usejest.useFakeTimers()to run the sweep interval, and callstop()inafterEach. -
[ ] Step 2: Run
npx jest tests/rateLimiter.test.js -t "evicts expired". Expected: FAIL with "size is not a function". -
[ ] Step 3: Add the sweep to
createRateLimiter. UsesetInterval(sweep, windowMs).unref(), wheresweepdeletes entries whoseresetAt <= now(). Attachsizeandstopto the returned handler. -
[ ] Step 4: Run
npx jest tests/rateLimiter.test.js. Expected: all PASS. -
[ ] Step 5: Commit
git add src/rateLimiter.js tests/rateLimiter.test.js git commit -m "feat: evict expired rate limit entries"
Task 4: Wire into the app
Files:
- Modify:
src/app.js, andpackage.jsonif supertest is missing (npm i -D supertest). - Test:
tests/app.rateLimit.test.js
Interfaces:
-
Consumes:
getRateLimitConfig(Task 1) andcreateRateLimiter(Task 2). -
Produces:
src/app.jsmountsapp.use(createRateLimiter(getRateLimitConfig()))before all routes. Before editing, check howsrc/app.jsexports the app (the app itself, or a factory). Read the env at app-creation time so tests can set it. -
[ ] Step 1: Write the failing test in
tests/app.rateLimit.test.js. Setprocess.env.RATE_LIMIT_MAX = '2'andRATE_LIMIT_WINDOW_MS = '60000'before requiring the app, usingjest.resetModules()if needed. Hit any existing route using the real app via supertest.real app returns 429 with Retry-After after exceeding configured limit: two requests are not 429, the third returns 429, and theretry-afterheader is present.
-
[ ] Step 2: Run
npx jest tests/app.rateLimit.test.js. Expected: FAIL because the third request returns 200. -
[ ] Step 3: Mount the limiter in
src/app.jsas the first middleware, before any routes. -
[ ] Step 4: Run the full suite with
npx jest. Expected: all PASS, including the existing tests. If an existing test makes more than 100 requests from one IP, setRATE_LIMIT_MAXhigher in that test's setup. -
[ ] Step 5: Document
RATE_LIMIT_MAX(default 100) andRATE_LIMIT_WINDOW_MS(default 60000) in the README, or in a new.env.exampleif no README exists. -
[ ] Step 6: Commit
git add src/app.js tests/app.rateLimit.test.js package.json README.md git commit -m "feat: apply rate limiting to API"
Self-Review
- Spec coverage: the 100/min per-IP limit and the 429 with
Retry-Afterare in Tasks 2 and 4. Env configuration is in Task 1. The in-memory store is in Tasks 2 and 3. Jest tests are in every task. - Step scan: each step names its file, signature and assertions, with no bodies written out.
- Type consistency:
{ max, windowMs }andcreateRateLimiterare used identically across Tasks 1 to 4.size()andstop()are defined only in Task 3. - Review Focus: each of the five items has an owning test, in Tasks 1, 2 and 3.
- Open assumption: Task 4 depends on how
src/app.jsexports the app. I haven't read that file, so the implementer should check it first.
Plan complete, and it is printed above rather than saved because I can't write files here. Please review it. Which execution approach would you prefer?
- Subagent-driven: a fresh subagent implements each task and a fresh reviewer checks it before the next one starts. This is the most thorough option.
- Native: I implement every task myself in this session, then one fresh reviewer checks the whole branch. This is the cheapest and fastest option.
For this plan I recommend Native. There are only four small tasks, and the interfaces are narrow and fully specified. A mistake in an in-memory limiter is cheap to catch and fix. Does the plan capture what you want, and which approach should we use?
Real run in an isolated folder with only this skill installed. Only the Skill and Read tools were enabled, so no command was run and no file was written. The example is fictional. The model could not save the plan to a file and printed it inline. The spec is invented.
하는 일
이 코드베이스도 명세도 본 적 없는 엔지니어를 위해 계획을 씁니다. 계획에는 구현자가 혼자 결정할 수 없는 것을 적습니다. 어떤 파일인지, 어떤 이름과 시그니처인지, 명세의 어떤 값인지, 어떤 테스트로 각 작업을 증명하는지입니다. 계획은 머리글로 시작하며(목표, 아키텍처, 기술 스택, 대상 명세, 전역 제약, 명세가 암시하지만 테스트가 다루지 않는 입력의 '리뷰 초점' 목록), 각 작업에는 만들거나 수정할 파일, 쓰는 쪽과 제공하는 쪽 인터페이스, 잘게 나눈 테스트 선행 단계(실패하는 테스트 작성, 실행, 구현, 실행, 커밋)를 명령과 기대 출력과 함께 적습니다. 자체 점검으로 명세 포괄성, 단계의 명확성, 타입 일관성, 리뷰 초점, 분량을 확인하고, 마지막에 사용자에게 검토를 요청하고 실행 방식을 고르게 합니다.
이런 때 좋습니다
서면 명세가 있는 여러 단계의 기능, 다른 사람이나 에이전트에게 일을 넘길 때, 작업을 작고 테스트 가능하게 유지할 때.
중간 위험:계획을 프로젝트의 `docs/superpowers/plans/<날짜>-<기능>.md`로 저장하므로 그곳에 파일을 만듭니다. 작성되는 계획에는 `git commit` 단계가 들어 있고 실행자에게 `subagent-driven-development` 또는 `executing-plans` 스킬을 쓰라고 지시하는데, 둘 다 이 사이트에는 등록되어 있지 않으므로 계획을 실행할 다른 방법이 필요합니다. 네트워크 요청은 하지 않고 스스로 명령을 실행하지도 않습니다. 가상의 명세로 한 번 시험 실행했고, 모델은 파일을 쓸 수 없어 계획을 그대로 출력했습니다.