fix(rate_limiter): use a threading.Lock for the synchronous methods (asyncio.Lock can't use plain with)#923
Conversation
RateLimiter creates self._lock = asyncio.Lock() and correctly uses it as `async with` in the async _wait_if_needed path. But the synchronous methods get_stats/reset and AdaptiveRateLimiter.record_success/record_error/get_stats acquire the same asyncio lock with a plain `with self._lock:`. asyncio.Lock implements only __aenter__/__aexit__, so `with` raises TypeError: "'asyncio.locks.Lock' object does not support the context manager protocol". Every one of those sync calls fails -- e.g. the findata fetcher calls record_success()/record_error() around each adaptive-rate-limited fetch, so the first success throws and the except handler's record_error throws again. Add a dedicated threading.Lock (self._state_lock) for the sync bookkeeping methods; the async request-pacing path keeps the asyncio lock.
There was a problem hiding this comment.
Code Review
This pull request introduces a synchronous threading.Lock (self._state_lock) to replace asyncio.Lock in the synchronous bookkeeping methods of RateLimiter (such as get_stats, reset, record_success, and record_error) to prevent runtime errors when using a plain with statement. However, the review highlights two critical issues: first, a synchronization gap exists because the asynchronous _wait_if_needed method still accesses shared state under asyncio.Lock, which can lead to race conditions and runtime errors when synchronous methods are called concurrently from other threads. Second, in AdaptiveRateLimiter, dynamically adjusting max_requests_per_second does not update the immutable maxlen of the underlying deque (self._request_times), which can break the rate-limiting logic.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| # Separate synchronous lock for the non-async bookkeeping methods | ||
| # (get_stats/reset/record_success/record_error); an asyncio.Lock only | ||
| # supports `async with`, so using it under a plain `with` raises. | ||
| self._state_lock = threading.Lock() |
There was a problem hiding this comment.
Synchronization Gap between asyncio.Lock and threading.Lock
While introducing self._state_lock (a threading.Lock) prevents the TypeError in synchronous methods, it introduces a critical thread-safety issue.
The asynchronous _wait_if_needed method still accesses and mutates self._request_times and self._last_request_time under self._lock (an asyncio.Lock), which does not synchronize with self._state_lock.
Since synchronous methods like get_stats, reset, record_success, and record_error can be called from other threads (e.g., when running synchronous tools in a thread pool via run_in_executor), they can access or mutate self._request_times concurrently with _wait_if_needed. This will lead to race conditions and potential RuntimeError: deque mutated during iteration in get_stats.
Recommended Fix
To ensure thread safety without blocking the asyncio event loop during await points, use self._state_lock inside _wait_if_needed to protect all synchronous state accesses and mutations, but release it before any await calls.
Here is how _wait_if_needed should be refactored:
async def _wait_if_needed(self):
now = time.time()
# 1. Check minimum request interval safely
with self._state_lock:
last_request_time = self._last_request_time
min_request_interval = self.min_request_interval
if last_request_time > 0:
elapsed = now - last_request_time
if elapsed < min_request_interval:
wait_time = min_request_interval - elapsed
logger.debug(f'Enforcing min interval: waiting {wait_time:.3f}s')
await asyncio.sleep(wait_time)
now = time.time()
# 2. Check requests per second limit
async with self._lock:
with self._state_lock:
# Clean up expired request records
cutoff_time = now - 1.0
while self._request_times and self._request_times[0] < cutoff_time:
self._request_times.popleft()
# Check if rate limit reached
has_reached_limit = len(self._request_times) >= self.max_requests_per_second
wait_time = 1.0 - (now - self._request_times[0]) + 0.01 if has_reached_limit else 0
if has_reached_limit and wait_time > 0:
logger.debug(f'Rate limit reached: waiting {wait_time:.3f}s')
await asyncio.sleep(wait_time)
now = time.time()
with self._state_lock:
cutoff_time = now - 1.0
while self._request_times and self._request_times[0] < cutoff_time:
self._request_times.popleft()
with self._state_lock:
self._request_times.append(now)
self._last_request_time = now| def record_success(self): | ||
| """Record successful request""" | ||
| with self._lock: | ||
| with self._state_lock: |
There was a problem hiding this comment.
Immutable deque maxlen Bug in AdaptiveRateLimiter
In AdaptiveRateLimiter, the rate limit self.max_requests_per_second is dynamically adjusted in record_success and record_error. However, self._request_times is initialized in the parent class constructor as a deque with a fixed maxlen:
self._request_times = deque(maxlen=max_requests_per_second)Since a deque's maxlen is immutable after instantiation, changing self.max_requests_per_second does not update the capacity of self._request_times.
- If the rate limit increases (e.g., from 2 to 10),
self._request_timesremains capped at 2 elements. Consequently,len(self._request_times)will never reach 10, and the rate limiter will never trigger a wait, effectively disabling rate limiting. - If the rate limit decreases, the deque will still allow more elements than the new limit before discarding.
Recommended Fix
Whenever self.max_requests_per_second is updated, recreate self._request_times with the new maxlen to preserve the existing history up to the new limit:
self._request_times = deque(self._request_times, maxlen=new_rps)For example, in record_success:
if new_rps > old_rps:
self.max_requests_per_second = new_rps
self._request_times = deque(self._request_times, maxlen=new_rps)
logger.info(...)And similarly in record_error whenever self.max_requests_per_second is decreased.
…aptive deque Addresses review feedback: - _wait_if_needed mutated _request_times/_last_request_time under only the asyncio lock, which does not synchronize with the threading _state_lock used by the synchronous bookkeeping methods (callable from other threads). Guard those state accesses with _state_lock too, in brief critical sections that are never held across an await; _lock still serializes coroutines. - AdaptiveRateLimiter adjusts max_requests_per_second, but _request_times was a deque with a fixed maxlen, so raising the rate never took effect (len could never reach the new higher limit). Recreate the deque with the new maxlen (preserving recent history) whenever the rate changes.
|
Thanks @gemini-code-assist — both are valid, addressed in 18e97f5: 1. Lock synchronization gap. You're right that 2. Immutable deque maxlen. Also correct — raising Verified with a standalone async harness (the full suite needs |
|
Thanks for the detailed follow-up, @Osamaali313. Your approach to using |
Problem
RateLimiter(ms_agent/utils/rate_limiter.py) creates its lock as an asyncio primitive and uses it correctly in the async path:But the synchronous bookkeeping methods acquire that same asyncio lock with a plain
with:RateLimiter.get_stats/resetAdaptiveRateLimiter.record_success/record_error/get_statsasyncio.Lockimplements only__aenter__/__aexit__, sowith self._lock:raisesTypeError: 'asyncio.locks.Lock' object does not support the context manager protocolon every call. The correctasync with self._locksibling in the same class shows these were meant to be locked, just not with the async lock.Impact (live path)
ms_agent/tools/findata/findata_fetcher.pycallsrecord_success()after each successful adaptive-rate-limited fetch andrecord_error(...)in itsexcepthandler. So withrate_limiter: {enabled: true, type: adaptive}, the first successful fetch'srecord_success()throwsTypeError; the fetcher'sexceptthen callsrecord_error(), which throws again and propagates — the fetch fails.Reproduction
TypeError: 'asyncio.locks.Lock' object does not support the context manager protocolFix
Add a dedicated
threading.Lockfor the synchronous methods; the async request-pacing path keeps theasyncio.Lock:AdaptiveRateLimiter.__init__callssuper().__init__, so it inherits_state_lock. Verified RED→GREEN on the extracted logic;python -m py_compilepasses. (No existing tests coverrate_limiter; I couldn't import the full class locally becausems_agent.configpullsmodelscope, which isn't installed in this env — CI will exercise it.)