Skip to content

feat: add per-namespace agent rate limiting - #532

Open
ashike24 wants to merge 13 commits into
GenAI-Security-Project:mainfrom
ashike24:feat/week5-6-agent-rate-limiting
Open

feat: add per-namespace agent rate limiting#532
ashike24 wants to merge 13 commits into
GenAI-Security-Project:mainfrom
ashike24:feat/week5-6-agent-rate-limiting

Conversation

@ashike24

@ashike24 ashike24 commented Jun 25, 2026

Copy link
Copy Markdown

Summary

Adds per-namespace rate limiting to all agent-triggering endpoints in the FinBot vendor portal, protecting the shared LLM quota from exhaustion and ensuring fair access across namespaces.

Motivation

Without rate limiting, a single namespace generating a burst of requests could consume the entire available LLM quota, degrading the experience for every other user of the platform. This change caps the number of agent-invoking requests per namespace within a fixed time window.

Changes

finbot/config.py

  • Added AGENT_RATE_LIMIT_MAX (default: 10) and AGENT_RATE_LIMIT_WINDOW_SECONDS (default: 60) to the Settings class with full env var override support.

finbot/core/ratelimit/limiter.py (new)

  • Fixed-window Redis counter keyed on finbot:ratelimit:{namespace}:agent.
  • Reuses the existing event_bus.redis async client — no second Redis connection.
  • Raises HTTP 429 with a descriptive message including current count, max, and TTL remaining.
  • Fails open on Redis errors to avoid blocking all users due to infrastructure issues.

finbot/core/ratelimit/__init__.py (new)

  • Package marker.

finbot/apps/vendor/routes/api.py

  • Added Depends(check_agent_rate_limit) to 5 agent-triggering routes: POST /vendors/register, POST /vendors/{vendor_id}/request-review, POST /invoices, POST /invoices/{invoice_id}/reprocess, and POST /chat.

Test Results

10 integration tests added in tests/integration/test_rate_limiting.py, all passing. No regressions in the existing suite (382 passed, 6 pre-existing failures unchanged).

@ashike24 ashike24 changed the title feat: add per-namespace agent rate limiting (Week 5-6) feat: add per-namespace agent rate limiting Jun 26, 2026
ashike24 added 2 commits July 4, 2026 23:36
- New finbot/mcp_server/ package with submit_tool_call and
  get_scoring_results tools, wrapping VendorChatAssistant and
  UserChallengeProgressRepository respectively
- Auth via existing SessionManager (session_id -> SessionContext),
  no new auth mechanism needed
- Runs as separate Docker service (mcp_server) on port 8100,
  sharing sqlite_data volume + Redis with the main app
- Fixed pre-existing .env bug: DATABASE_URL pointed outside the
  mounted sqlite_data volume, causing data loss on container
  rebuild for both services
- Verified end-to-end: get_scoring_results fully working;
  submit_tool_call reaches the LLM call correctly, blocked only
  by a missing OPENAI_API_KEY in local dev env (pre-existing gap,
  unrelated to this change)
- start_challenge_session from original proposal wording dropped:
  no such concept exists in the codebase; sessions already exist
  via login and challenges progress implicitly
try:
# Fix 2: Explicitly guard Redis initialization
redis = getattr(event_bus, "redis", None)
if redis is None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fail-open rate limiting

If Redis is unavailable or not initialized, the middleware allows every request through, effectively disabling rate limiting. For LLM/agent endpoints, this creates a potential abuse vector during Redis outages.

Consider failing closed (HTTP 503/429) or using an in-memory fallback rate limiter instead of bypassing rate limiting completely.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. The limiter now fails closed - if Redis is unavailable or uninitialized, the request is blocked with HTTP 503 rather than allowed through.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Correction: that "fixed" reply above was also inaccurate - the fail-closed change was part of the same stranded commit I mentioned in the other thread on this file, and never actually reached this PR. Apologies for the confusion.

Having looked at this more carefully, I'd like to actually keep fail-open rather than switch to fail-closed, and wanted to explain the reasoning rather than just silently leave it as-is. Rate limiting here is a soft quota guard on LLM usage, not an auth/security control - it doesn't gate access to anything sensitive. If it fails closed, a Redis outage (even a brief one) would take down every agent-triggering endpoint platform-wide (chat, vendor onboarding, invoice processing) since they all depend on this same dependency. That feels like a worse outcome than the actual risk being guarded against, which is a namespace temporarily sending more LLM requests than intended during a rare outage window.

Completely open to being wrong here if there's a stronger reason to prioritize fail-closed for this specific case - happy to discuss further if you still think it should block instead.

Comment thread finbot/core/ratelimit/limiter.py Outdated

# On the first request in a window, set the expiry
if count == 1:
await redis.expire(key, window_seconds)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can you check this -
Race condition between INCR and EXPIRE

INCR and EXPIRE are executed as separate Redis commands. If the process crashes or is interrupted after INCR succeeds but before EXPIRE executes, the key is created without a TTL. Since subsequent requests will observe count > 1, the expiration is never set, leaving a permanent rate-limit key.

Consider making these operations atomic using a Redis transaction/pipeline

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. INCR and EXPIRE are now executed atomically using a Redis pipeline with transaction=True, eliminating the race condition where a crash between the two commands could leave a permanent key with no TTL.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Update: the pipeline approach I described above turned out to have real problems once I dug into it - it also switched EXPIRE to run on every request (not just the first, changing the fixed-window behavior) and it dropped the Retry-After header along the way. It was also accidentally left stranded on a branch and never actually reached this PR, so this thread has been unresolved longer than my earlier reply suggested - sorry about that.

Replaced it with a single atomic Lua script (EVAL) that does the INCR and conditionally sets EXPIRE (only on the first request in the window) in one Redis round trip, in commit ca197e0. This fully removes the race you flagged - there's no window between the two ops where a crash could leave an orphaned key - while keeping the original fixed-window semantics and the Retry-After header intact.

Verified live against real Redis in Docker (10 requests succeed, 11th correctly returns 429, no fail-open errors logged) and via the full test suite (398 passed, 0 regressions).

ashike24 added a commit to ashike24/finbot-ctf that referenced this pull request Aug 12, 2026
mekaizen flagged a race condition on PR GenAI-Security-Project#532: if the process crashed
or lost its Redis connection between the separate INCR and EXPIRE
calls, a key could be left permanently incremented with no TTL,
silently blocking that namespace forever.

A previous attempt to fix this (commit a03481a) used a Redis pipeline
and switched the limiter to fail-closed on Redis errors, but that
commit was accidentally left stranded on a feature branch and never
merged - and it also changed EXPIRE to run on every request instead
of only the first, altering the fixed-window semantics, and would
have broken the existing test_expire_only_set_on_first_request test
and removed the just-added Retry-After header.

This commit takes a narrower approach: a single atomic Lua script
(EVAL) that performs the INCR and conditionally sets EXPIRE (only on
the first request in the window) in one Redis round trip. This fully
eliminates the race - there is no window between the two operations
where a crash could leave an orphaned key - while preserving the
original fixed-window behavior, the Retry-After header, and the
deliberate fail-open design (rate limiting is a soft quota guard, not
an auth control, so a Redis outage should not take down agent
endpoints entirely).

Test suite updated to mock eval() instead of incr()/expire()
separately, with a new test asserting the operation happens in a
single call. Verified live against real Redis in Docker: 10 requests
succeed, 11th correctly returns 429, no fail-open errors logged.
Full suite: 398 passed, 26 skipped, 6 pre-existing failures unchanged,
0 regressions.
Commit a03481a on this branch switched INCR+EXPIRE to a Redis pipeline
and changed the limiter to fail-closed (503) on Redis errors. That
approach had three problems: it ran EXPIRE on every request instead
of only the first (changing the fixed-window semantics), it dropped
the Retry-After header and negative-TTL guard, and fail-closed would
mean any Redis outage takes down agent endpoints entirely.

Replaced with a single atomic Lua script (EVAL) that performs the
INCR and conditionally sets EXPIRE only on the first request in the
window - fully eliminating the original race condition without the
above side effects. Kept the deliberate fail-open design: rate
limiting is a soft quota guard, not an auth control.

Verified live against real Redis in Docker (10 requests succeed,
11th correctly returns 429) and via the full test suite (398 passed,
0 regressions).
@ashike24

Copy link
Copy Markdown
Author

Closing this in favor of a single combined final submission: #567, which includes all the real work from this PR plus the fixes discussed above (see the threads on this PR for details on what changed and why).

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.

2 participants