Skip to content

Add asyncio.Lock around rate limiter bucket state - #119

Merged
peaktwilight merged 1 commit into
mainfrom
fix/rate-limiter-asyncio-lock
Apr 21, 2026
Merged

Add asyncio.Lock around rate limiter bucket state#119
peaktwilight merged 1 commit into
mainfrom
fix/rate-limiter-asyncio-lock

Conversation

@peaktwilight

@peaktwilight peaktwilight commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • src/opensoar/middleware/rate_limit.py held its token-bucket state in a module-level dict[str, list[float]] with no synchronization, so two coroutines could both observe an under-capacity bucket and both append, slipping more than max_requests through the window.
  • Added a lazily-initialized module-level _lock: asyncio.Lock | None (via _get_lock()) so we don't assume a running event loop at import time.
  • Wrapped the read-modify-write of _buckets[key] in RateLimitMiddleware.dispatch with async with lock: to make concurrency guarantees explicit.

Test plan

  • ruff check src/ tests/ passes
  • pytest tests/ passes (549 passed)
  • New concurrency tests in tests/test_rate_limit.py::TestRateLimitConcurrency:
    • test_lock_is_asyncio_lock_get_lock() returns an asyncio.Lock and is idempotent
    • test_concurrent_dispatch_exact_countmax_requests + 1 concurrent requests yield exactly max_requests successes and exactly one 429
    • test_concurrent_dispatch_no_list_corruption — after N concurrent successes the bucket holds exactly N entries
  • test_lock_is_asyncio_lock verified to fail against the pre-fix implementation

Closes #106

Summary by CodeRabbit

  • Bug Fixes
    • Fixed race condition in rate limiting to properly handle concurrent requests, ensuring accurate request counting and consistent enforcement of rate limits under high load.

The in-memory token-bucket rate limiter in
`src/opensoar/middleware/rate_limit.py` read and mutated a module-level
`_buckets: dict[str, list[float]]` without any synchronization. Under
concurrent webhook load, two coroutines could both observe an
under-capacity bucket and both append, allowing more than
`max_requests` through the window.

Fix:

- Add a module-level `_lock: asyncio.Lock | None = None` guard.
- Initialize it lazily via `_get_lock()` on first async call so we
  don't require a running event loop at import time.
- Hold the lock across the read-modify-write of `_buckets[key]` in
  `RateLimitMiddleware.dispatch`.

Tests cover:

- `_get_lock()` returns an `asyncio.Lock` and is idempotent.
- `max_requests + 1` concurrent dispatches yield exactly `max_requests`
  successes and exactly one 429.
- After N concurrent successes the bucket holds exactly N entries.

Closes #106
@coderabbitai

coderabbitai Bot commented Apr 21, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Added asyncio.Lock-based synchronization to the rate limiter module to protect concurrent access to the shared _buckets dictionary. Introduced a lazy-initialized _get_lock() function and updated the dispatch() method to acquire the lock around rate-check and bucket operations. Added comprehensive concurrency tests validating thread-safety.

Changes

Cohort / File(s) Summary
Rate Limiter Synchronization
src/opensoar/middleware/rate_limit.py
Introduced _get_lock() for lazy lock initialization and wrapped bucket access (cleanup, rate checks, append) in dispatch() with lock acquisition to ensure atomic token-bucket operations under concurrent requests.
Concurrency Test Suite
tests/test_rate_limit.py
Added TestRateLimitConcurrency with three tests: verification of asyncio.Lock initialization and reuse, concurrent dispatch correctness (max_requests + 1 calls verify exactly one 429), and bucket state integrity under concurrent access.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A lock guards the bucket so tight,
Concurrent hops dance in the light,
No more races, safe and sound,
Token-bucket magic all around! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding asyncio.Lock synchronization around the rate limiter's bucket state, which is the core objective of the PR.
Linked Issues check ✅ Passed The PR fully addresses issue #106 by implementing asyncio.Lock protection around bucket access and adding comprehensive concurrency tests that validate the fix.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing the rate limiter concurrency issue: lock implementation in rate_limit.py and concurrency tests in test_rate_limit.py.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/rate-limiter-asyncio-lock

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/opensoar/middleware/rate_limit.py`:
- Around line 15-28: The module-level lazy async lock (_lock and _get_lock) is
unnecessary and the async-with guard around synchronous list ops adds overhead;
remove the _lock variable and the _get_lock() function and eliminate usage sites
that do "async with await _get_lock()" (replace them with the synchronous code
path that currently runs inside the block). If you actually need cross-thread
protection instead, replace the pattern with a threading.Lock instantiated at
module import and use regular with threading_lock; otherwise simply remove the
lock and its awaits so functions that referenced _get_lock (and the symbols
_lock/_get_lock) operate without the redundant async synchronization.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f61e65fe-f420-4534-9eb3-ccd3ea7080d9

📥 Commits

Reviewing files that changed from the base of the PR and between 1dbc05a and ae75785.

📒 Files selected for processing (2)
  • src/opensoar/middleware/rate_limit.py
  • tests/test_rate_limit.py

Comment on lines +15 to +28
# Lock that guards access to ``_buckets``. Lazily initialized on first async
# call because there may not be a running event loop at import time (issue #106).
_lock: asyncio.Lock | None = None


async def _get_lock() -> asyncio.Lock:
"""Return the module-level asyncio.Lock, creating it on first use.

Must be called from within a running event loop.
"""
global _lock
if _lock is None:
_lock = asyncio.Lock()
return _lock

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python - <<'PY'
import ast
import asyncio
from pathlib import Path

path = next(Path('.').rglob('rate_limit.py'))
tree = ast.parse(path.read_text())

for node in tree.body:
    if isinstance(node, ast.ClassDef) and node.name == 'RateLimitMiddleware':
        for fn in node.body:
            if isinstance(fn, ast.AsyncFunctionDef) and fn.name == 'dispatch':
                awaits = sorted({n.lineno for n in ast.walk(fn) if isinstance(n, ast.Await)})
                print("dispatch await lines:", awaits)

lock = asyncio.Lock()
print("constructed lock type:", type(lock).__name__)
PY

Repository: opensoar-hq/opensoar-core

Length of output: 131


🏁 Script executed:

cat -n src/opensoar/middleware/rate_limit.py | sed -n '15,90p'

Repository: opensoar-hq/opensoar-core

Length of output: 3069


The lock guards only synchronous operations and is unnecessary; also, the lazy initialization pattern is based on a misunderstanding of asyncio.Lock() behavior.

The code at lines 71–85 inside async with lock: contains no await statements—only list operations, comparisons, and a conditional return. On a single event loop, coroutines can only be preempted at await points, so this synchronous block runs atomically without needing explicit synchronization. The lock adds no real protection for the current code path.

Additionally, the comment at lines 15–16 claims asyncio.Lock() cannot be constructed without a running event loop, but asyncio.Lock() can be safely constructed at import time in modern Python. The _get_lock() wrapper function adds unnecessary indirection to solve a non-existent problem. If concurrent access from threads or processes is the actual concern, asyncio.Lock does not solve that anyway.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/opensoar/middleware/rate_limit.py` around lines 15 - 28, The module-level
lazy async lock (_lock and _get_lock) is unnecessary and the async-with guard
around synchronous list ops adds overhead; remove the _lock variable and the
_get_lock() function and eliminate usage sites that do "async with await
_get_lock()" (replace them with the synchronous code path that currently runs
inside the block). If you actually need cross-thread protection instead, replace
the pattern with a threading.Lock instantiated at module import and use regular
with threading_lock; otherwise simply remove the lock and its awaits so
functions that referenced _get_lock (and the symbols _lock/_get_lock) operate
without the redundant async synchronization.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying opensoar-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: ae75785
Status: ✅  Deploy successful!
Preview URL: https://91f55549.opensoar-docs.pages.dev
Branch Preview URL: https://fix-rate-limiter-asyncio-loc.opensoar-docs.pages.dev

View logs

@peaktwilight
peaktwilight merged commit d765912 into main Apr 21, 2026
9 checks passed
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.

Rate limiter uses module-level dict without asyncio locking

1 participant