Skip to content

fix(api): report remaining time in Retry-After - #1603

Merged
mgoldsborough merged 2 commits into
NimbleBrainInc:mainfrom
Coding-Professional:fix/1498-retry-after
Oct 7, 2026
Merged

mgoldsborough merged 2 commits into
NimbleBrainInc:mainfrom
Coding-Professional:fix/1498-retry-after

Conversation

@Coding-Professional

@Coding-Professional Coding-Professional commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Problem

RequestRateLimiter uses a fixed window starting with a key's first request, but requestRateLimit always returned the full window in Retry-After. For a 60-second window, a request rejected 50 seconds in was told to wait another 60 seconds instead of 10. The class comment also incorrectly called this a sliding window.

Change

  • Compute the remaining time from the same clock reading that rejects the request, round it up to whole seconds, and send it in Retry-After. Near the boundary, a rejected request receives 1, not 60; at the boundary, the next request starts a new window.
  • Keep consume(key): boolean unchanged for existing webhook callers. The middleware uses consumeWithRetryAfter(key), which returns null when allowed or the retry delay when rejected.
  • Correct the fixed-window comment and document the 429 header in the API environment reference.

Coverage

Deterministic tests check the remaining delay halfway through the window, one millisecond before reset, and admission at reset. The middleware test checks the emitted header. Existing real-time header assertions now accept the valid 1–60 second range so they do not depend on machine speed.

Related issue

Closes #1498

Verification

  • bun test --no-env-file test/unit/rate-limiter.test.ts test/unit/api/rate-limit-middleware.test.ts test/integration/api/request-rate-limit.test.ts — 17 passed.
  • Root TypeScript check, Biome check, and format check passed. The web test TypeScript config also passed when invoked directly.
  • cd docs && bun run build — 70 pages built; internal links valid.
  • bun run verify was attempted on Windows but stopped at check:web-tests: its script expects .bin/tsc, whereas Bun installs tsc.exe here. A separate full unit run encountered Windows path/file-mode failures and stalled in an OAuth timer test. These are outside the changed files; Linux CI remains the full-suite check.

Checklist

  • Linked the resolved issue with a closing keyword.
  • Updated docs/ for the API behavior change.
  • Ran bun run verify and the relevant checks; noted the Windows limitations above.

Copilot AI balanced review requested due to automatic review settings October 5, 2026 09:10
@Coding-Professional
Coding-Professional requested a review from a team as a code owner October 5, 2026 09:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@mgoldsborough

Copy link
Copy Markdown
Contributor

QA Review: fix/1498-retry-after

Scope: 6 files, +59/−11 · reviewed: 25967370
Risk: LOW
Why this exists: a 429 from the chat, tool, or MCP limiter told clients to wait the full 60 s window even when only a few seconds remained, so they backed off too long and every rejected caller retried at the same moment (#1498).

Critical (must fix)

None.

Fix in-PR (apply mechanically — no adjudication essay expected)

  • r1f1 [src/api/rate-limiter.ts:15] windowSeconds has no caller in src/ any more. Its only user was the middleware's old Retry-After, and now only its own test (test/unit/rate-limiter.test.ts:49, "exposes windowSeconds") reads it. Delete the getter and that test.
  • r1f2 [docs/src/content/docs/config/environment.mdx:43] "that identity's fixed one-minute window" is wrong for /mcp. That limiter keys on identity and grant kind (src/api/routes/mcp.ts:124), so one identity can hold two windows. Say "that caller's" instead.

Suggestions (optional)

  • r1s1 [src/api/routes/hooks.ts:216] The webhook receiver's 429 still sends a fixed Retry-After: 60, which is the same issue on a different surface. It is outside Rate limiter's Retry-After is always the full window, not the time left #1498's charter, and consume is deliberately left unchanged, so this is not filed. If you are already in that file, the same consumeWithRetryAfter swap applies.

What Looks Good

  • The remaining time is computed from the same Date.now() reading that rejects the request, so the header and the window cannot disagree. A rejection always yields ≥ 1, because now - windowStart < windowMs on that branch.
  • consume() delegates to the new method, so the webhook callers keep their exact semantics without a second copy of the window logic.
  • Tests are deterministic (Date.now spy). A mutation check confirmed they bite: restoring String(limiter.windowSeconds) in the middleware fails "returns only the remaining seconds in Retry-After".
  • The class comment now says fixed window, which matches the code.

Body claims

  • ✅ "17 passed" for the three named test files: reproduced locally, 17 pass / 0 fail.
  • ✅ consume(key): boolean is unchanged for webhook callers (hooks.ts:184, :203).
  • ✅ The tests cover halfway, 1 ms before reset, and admission at reset (rate-limiter.test.ts). The middleware test asserts the emitted header.
  • ℹ️ CI has not run: both workflows show action_required (a fork PR waiting for maintainer approval). The full suite on Linux is still unverified.

Next step for the author: answer these by id (the pr-adjudicate flow). Applying r1f1/r1f2 with one line each is enough.

machine record
{
  "kind": "review", "pr": 1603, "round": 1,
  "reviewed": "25967370b4a2866964577fb76004b2bbc19abd5b", "delta_base": null,
  "risk": "LOW", "stop_gate": "n/a",
  "verdict": "Correct core; two mechanical cleanups; CI awaiting fork approval",
  "findings": [
    {"id": "r1f1", "bucket": "fix-in-pr", "file": "src/api/rate-limiter.ts", "line": 15,
     "claim": "windowSeconds getter has no src caller after this change; only its own test reads it",
     "evidence": "reproduced", "receipt": "grep -rn windowSeconds src/ -> only the definition",
     "preconditions": null, "fix": "delete getter and the 'exposes windowSeconds' test", "scope": "core"},
    {"id": "r1f2", "bucket": "fix-in-pr", "file": "docs/src/content/docs/config/environment.mdx", "line": 43,
     "claim": "doc says the window is per identity, but the MCP limiter keys on identity plus grant kind",
     "evidence": "reproduced", "receipt": "src/api/routes/mcp.ts:124 keyOf = `${identity.id}:${grant.kind}`",
     "preconditions": null, "fix": "say \"that caller's\" window", "scope": "peripheral"},
    {"id": "r1s1", "bucket": "suggestion", "file": "src/api/routes/hooks.ts", "line": 216,
     "claim": "webhook 429 still sends a fixed Retry-After: 60",
     "evidence": "reproduced", "receipt": "sed -n 214,217p src/api/routes/hooks.ts",
     "preconditions": null, "fix": "use consumeWithRetryAfter in the hooks route", "scope": "peripheral"}
  ]
}

@mgoldsborough

Copy link
Copy Markdown
Contributor

Adjudication: round 1

The three rate-limit test files pass (16, after dropping the windowSeconds test). tsc --noEmit and biome are clean.

machine record
{"kind": "adjudication", "pr": 1603, "round": 1, "fixed_at": "37458c75923c9c00a86710e6a2c41053d474e58d",
 "findings": [
  {"id": "r1f1", "verdict": "accepted", "fixed_at": "37458c75"},
  {"id": "r1f2", "verdict": "accepted", "fixed_at": "37458c75"},
  {"id": "r1s1", "verdict": "split", "issue": 1649}
 ]}

@mgoldsborough mgoldsborough added the qa-reviewed QA review completed with no critical issues label Oct 7, 2026
@mgoldsborough
mgoldsborough merged commit 39b7091 into NimbleBrainInc:main Oct 7, 2026
9 checks passed
@mgoldsborough

Copy link
Copy Markdown
Contributor

Thanks for the contribution @Coding-Professional

Follow up issue if you want to grab that too: #1649

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

qa-reviewed QA review completed with no critical issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rate limiter's Retry-After is always the full window, not the time left

3 participants