Skip to content

feat: add cross-process rate-limit coordination for Semantic Scholar - #28

Merged
Burton-David merged 1 commit into
mainfrom
feat/shared-rate-limit
May 31, 2026
Merged

feat: add cross-process rate-limit coordination for Semantic Scholar#28
Burton-David merged 1 commit into
mainfrom
feat/shared-rate-limit

Conversation

@Burton-David

Copy link
Copy Markdown
Owner

What

AdaptiveRateLimiter is process-local, so a research-mcp repl next to Claude Desktop's research-mcp serve (plus an ad-hoc script) each respect S2's limit individually but collectively exceed it — the user sees more 429s than they should.

SharedAdaptiveRateLimiter (sources/_rate_limit_shared.py) is a drop-in for AdaptiveRateLimiter (same acquire / record_failure / record_success / current_interval) that coordinates through a JSON sidecar at ~/.cache/research-mcp/rate-limits/<source>.json, guarded by an fcntl lock:

  • last_call and the adaptive interval are shared, so every process on the machine serializes against one minimum interval and one backoff (a 429 seen by one process slows the others).
  • acquire() reserves its slot — writes the time it will fire — while holding the lock, then sleeps outside the lock. The blocking lock + IO runs in asyncio.to_thread, so a sibling process holding the lock can't stall the event loop.
  • Wall-clock time.time() (comparable across processes, unlike the local limiter's monotonic); atomic tmp+rename writes; a missing/corrupt sidecar recovers to baseline.
  • record_* stay synchronous (they're invoked as backoff callbacks) and best-effort — a lock/IO failure never raises into the request path.

Opt in for Semantic Scholar with RESEARCH_MCP_S2_SHARED_RATELIMIT=1 (default off, so the test suite never touches shared ~/.cache state). POSIX only — fcntl; Windows is deferred.

Closes #8.

Test plan

  • ruff check src tests / mypy src — clean
  • pytest -q --strict-markers — 537 passed, 14 skipped
  • tests/unit/test_shared_rate_limit.py (all state_dir=tmp_path): constructor validation, base/failure/decay interval through the sidecar, cross-instance interval sharing, two instances serialize acquire() (the Cross-process rate-limit coordination (file-lock or shared state) #8 criterion), corrupt-sidecar recovery, atomic write (no leftover .tmp)
  • Verified the env flag selects the shared limiter (RESEARCH_MCP_S2_SHARED_RATELIMIT=1SharedAdaptiveRateLimiter, unset → AdaptiveRateLimiter) under a temp HOME

AdaptiveRateLimiter is process-local, so a 'research-mcp repl' next to Claude
Desktop's 'research-mcp serve' (plus an ad-hoc script) each respect S2's limit
individually but collectively exceed it.

SharedAdaptiveRateLimiter is a drop-in that coordinates through a JSON sidecar
(~/.cache/research-mcp/rate-limits/<source>.json) guarded by an fcntl lock:
last_call and the adaptive interval are shared, so all processes on one machine
serialize against one minimum interval and one backoff (a 429 in one slows the
others). acquire() reserves its slot under the lock then sleeps outside it, with
the blocking lock+IO on a worker thread so a sibling can't stall the loop. State
uses wall-clock time (comparable across processes) and atomic tmp+rename writes;
a missing/corrupt sidecar recovers to baseline.

Opt in for S2 with RESEARCH_MCP_S2_SHARED_RATELIMIT=1 (default off, so the test
suite never touches shared ~/.cache state). POSIX only — fcntl; Windows deferred.
@Burton-David
Burton-David merged commit a9a4ee9 into main May 31, 2026
1 check passed
@Burton-David
Burton-David deleted the feat/shared-rate-limit branch May 31, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cross-process rate-limit coordination (file-lock or shared state)

1 participant