feat: Add IP-based rate limiting to login endpoint (#13469)

* feat(security): add failed login logging with IP tracking and comprehensive tests

* fix: remove PII from login logs and fix structlog format

* docs: agent improvements (#13269)

* docs: add structured output for agents

* docs: add structured output note

* docs: clarify both agent outputs and add release notes

* docs: when langflow sends events to the playground and chat history

* fix(playground): expand session checkbox click target to full row height (#13349)

* fix(playground): expand session checkbox click target to full row height

The selectable-row checkbox wrapper was ``w-4 h-4`` (16x16 px) but the
row is ``h-8`` (32 px). The 8 px band above and the 8 px band below
the visible icon were dead zones that bubbled to the row's
``toggleVisibility`` and navigated to the session instead of toggling
selection — exactly the accidental-navigation UX the bug ticket
describes.

Grow the wrapper to ``w-4 h-8`` so the click target captures the full
row height. Width stays narrow so the text alignment is unchanged; the
icon itself stays ``h-4 w-4``, centered via the existing
``flex items-center justify-center``. Visual position is identical.

Adds ``__tests__/session-selector-checkbox-click-target.test.tsx``
(3 cases): wrapper className guard (h-8 + w-4, not h-4), click on the
wrapper outside the icon triggers selection without bubbling to
toggleVisibility, and click on the inner icon still toggles selection.

* test(playground): drop checkbox-click-target test file (not needed per review)

* [autofix.ci] apply automated fixes

* test: align session-selector checkbox assertions with full-height click target

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* fix: improve trace search functionality (#13383)

* improve trace search functionality

* [autofix.ci] apply automated fixes

---------

Co-authored-by: Olayinka Adelakun <olayinkaadelakun@Olayinkas-MacBook-Pro.local>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* test: fix test_login_logs_real_output_format to use mocking

- Updated test to use patch() instead of capsys for consistency
- Verifies structlog kwargs format (not nested extra dict)
- All 7 login logging tests now passing

* feat: Add IP-based rate limiting to login endpoint

- Add slowapi dependency for rate limiting functionality
- Implement rate limiting service with configurable limits
- Apply rate limiter to /login endpoint (default: 5 attempts/minute per IP)
- Add custom exception handler for rate limit exceeded (HTTP 429)
- Support both in-memory (default) and Redis storage backends
- Add comprehensive test suite with 100% coverage
- Document configuration in .env.example

Security improvements:
- Prevents brute force attacks on login endpoint
- Structured logging without PII (logs client_ip, not user data)
- Graceful degradation with swallow_errors=True
- Proper X-Forwarded-For header handling for proxied requests

Configuration:
- LANGFLOW_RATE_LIMIT_PER_MINUTE: requests per minute (default: 5)
- LANGFLOW_RATE_LIMIT_STORAGE: storage backend (default: memory://)
- LANGFLOW_RATE_LIMIT_HEADERS_ENABLED: enable rate limit headers (default: false)

* security: add proxy-aware IP extraction for rate limiting

- Add LANGFLOW_RATE_LIMIT_TRUST_PROXY configuration
- Default to secure get_remote_address (prevents header spoofing)
- Enable get_client_ip when behind trusted proxies
- Add comprehensive tests for both configurations
- Maintain 100% test coverage (15 tests passing)

* refactor(rate-limit): address PR review feedback - migrate to Settings model, fix IP security, add test coverage

* fix: disable rate limiting in tests to prevent 429 errors

- Add LANGFLOW_RATE_LIMIT_ENABLED environment variable control
- Disable rate limiting by default in tests via session-scoped fixture
- Rate limit tests explicitly enable it via function-scoped fixture
- Fixes test failures caused by sequential login attempts hitting 5/min limit

* fix: refactor login rate limiting to avoid settings initialization race condition

---------

Co-authored-by: Janardan S Kavia <janardanskavia@Janardans-MacBook-Pro.local>
Co-authored-by: Mendon Kissling <59585235+mendonk@users.noreply.github.com>
Co-authored-by: keval shah <kevalvirat@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: olayinkaadelakun <olayinka.adelakun@ibm.com>
Co-authored-by: Olayinka Adelakun <olayinkaadelakun@Olayinkas-MacBook-Pro.local>
This commit is contained in:
Janardan Singh Kavia
2026-06-04 08:42:34 -04:00
committed by GitHub
parent b4a8b08414
commit f76d3e22dd
11 changed files with 515 additions and 8 deletions

View File

@ -25,18 +25,17 @@ LANGFLOW_DATABASE_URL=sqlite:///./langflow.db
# Alembic logs path flag. If set to true, Alembic will log to stdout.
LANGFLOW_ALEMBIC_LOG_TO_STDOUT=False
# mem0 creates a directory
# mem0 creates a directory
# for chat history, vector stores, and other artifacts
# its default path is ~/.mem0.
# we can change this path with
# we can change this path with
# environment variable "MEM0_DIR"
# Example: MEM0_DIR=/tmp/.mem0
# composio creates a cache directory
# composio creates a cache directory
# for file uploads/downloads.
# its default path is ~/.composio
# we can change this path with
# we can change this path with
# environment variable "COMPOSIO_CACHE_DIR"
# Example: COMPOSIO_CACHE_DIR=/tmp/.composio
@ -131,6 +130,28 @@ LANGFLOW_API_KEY_SOURCE=
# Example: LANGFLOW_API_KEY=your-secure-api-key
LANGFLOW_API_KEY=
# Rate Limiting Configuration
# Controls rate limiting for login endpoint to prevent brute force attacks
# LANGFLOW_RATE_LIMIT_PER_MINUTE: Number of login attempts allowed per minute per IP
# Values: positive integer (default: 5)
# Example: LANGFLOW_RATE_LIMIT_PER_MINUTE=10
LANGFLOW_RATE_LIMIT_PER_MINUTE=
# LANGFLOW_RATE_LIMIT_STORAGE_URI: Storage backend for rate limit counters
# Values: memory:// (default, single server) or redis://host:port (production, multiple servers)
# - memory://: Fast, in-memory storage (not shared between server instances, lost on restart)
# - redis://localhost:6379: Persistent, shared storage (required for load-balanced deployments)
# Example: LANGFLOW_RATE_LIMIT_STORAGE_URI=redis://localhost:6379
LANGFLOW_RATE_LIMIT_STORAGE_URI=
# LANGFLOW_RATE_LIMIT_TRUST_PROXY: Trust X-Forwarded-For header for client IP
# Values: true, false (default: false)
# When true, uses rightmost X-Forwarded-For IP (the trusted proxy before us)
# When false, uses direct connection IP (prevents header spoofing in non-proxy deployments)
# SECURITY: Only enable if behind a trusted proxy (nginx, load balancer, etc.)
# Example: LANGFLOW_RATE_LIMIT_TRUST_PROXY=true
LANGFLOW_RATE_LIMIT_TRUST_PROXY=
# Should store environment variables in the database
# Values: true, false
LANGFLOW_STORE_ENVIRONMENT_VARIABLES=

View File

@ -2837,7 +2837,7 @@
"filename": "src/backend/tests/conftest.py",
"hashed_secret": "8bb6118f8fd6935ad0876a3be34a717d32708ffd",
"is_verified": false,
"line_number": 480,
"line_number": 489,
"is_secret": false
},
{
@ -2845,7 +2845,7 @@
"filename": "src/backend/tests/conftest.py",
"hashed_secret": "61fbb5a12cd7b1f1fe1624120089efc0cd299e43",
"is_verified": false,
"line_number": 690,
"line_number": 699,
"is_secret": false
}
],
@ -9287,5 +9287,5 @@
}
]
},
"generated_at": "2026-06-02T14:40:31Z"
"generated_at": "2026-06-03T21:32:15Z"
}

View File

@ -4,7 +4,10 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordRequestForm
from limits import parse
from pydantic import BaseModel
from slowapi.errors import RateLimitExceeded
from slowapi.wrappers import Limit
from langflow.api.utils import DbSession
from langflow.api.v1.schemas import Token
@ -12,10 +15,45 @@ from langflow.initial_setup.setup import get_or_create_default_folder
from langflow.services.database.models.user.crud import get_user_by_id
from langflow.services.database.models.user.model import UserRead
from langflow.services.deps import get_auth_service, get_settings_service, get_variable_service
from langflow.services.rate_limit import get_rate_limit_string
router = APIRouter(tags=["Login"])
def get_limiter_from_app(request: Request):
"""Get the rate limiter from app state (initialized after settings load)."""
return request.app.state.limiter
def check_rate_limit(request: Request) -> None:
"""Check and enforce rate limit for the request.
Retrieves the limiter from app.state (initialized after settings load in main.py)
and manually checks the rate limit using the limits library.
Raises:
RateLimitExceeded: If the rate limit is exceeded
"""
limiter = get_limiter_from_app(request)
# Parse the rate limit string and check if limit is exceeded
limit_item = parse(get_rate_limit_string())
if not limiter._limiter.hit(limit_item, limiter._key_func(request)): # noqa: SLF001
# Limit exceeded - raise RateLimitExceeded with proper wrapper
limit_wrapper = Limit(
limit=limit_item,
key_func=limiter._key_func, # noqa: SLF001
scope=None,
per_method=False,
methods=None,
error_message=None,
exempt_when=None,
cost=1,
override_defaults=False,
)
raise RateLimitExceeded(limit_wrapper)
class SessionResponse(BaseModel):
"""Session validation response."""
@ -31,6 +69,10 @@ async def login_to_get_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: DbSession,
):
"""Login endpoint with rate limiting applied via app.state.limiter."""
# Check rate limit (limiter is initialized in main.py after settings load)
check_rate_limit(request)
auth_settings = get_settings_service().auth_settings
try:
auth = get_auth_service()

View File

@ -757,6 +757,36 @@ def create_app():
content={"detail": exc.detail},
)
# Add rate limit exception handler
from slowapi.errors import RateLimitExceeded
@app.exception_handler(RateLimitExceeded)
async def rate_limit_exception_handler(request: Request, _exc: RateLimitExceeded):
"""Handle rate limit exceeded errors with structured logging."""
from langflow.services.rate_limit.service import get_limiter_key
# Default to 60 seconds for "/minute" window
retry_after_seconds = "60"
client_ip = get_limiter_key(request)
logger.warning(
"Rate limit exceeded",
auth_event="rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
method=request.method,
)
return JSONResponse(
status_code=HTTPStatus.TOO_MANY_REQUESTS,
content={
"detail": "Too many requests. Please try again later.",
"retry_after": retry_after_seconds,
},
headers={
"Retry-After": retry_after_seconds,
},
)
@app.exception_handler(Exception)
async def exception_handler(_request: Request, exc: Exception):
if isinstance(exc, HTTPException):
@ -778,6 +808,12 @@ def create_app():
add_pagination(app)
# Add SlowAPI state to app for rate limiting
from langflow.services.rate_limit import get_rate_limiter
limiter = get_rate_limiter()
app.state.limiter = limiter
return app

View File

@ -0,0 +1,12 @@
"""Rate limiting service for Langflow API endpoints.
This module provides rate limiting functionality using SlowAPI to protect
against brute force attacks and abuse. It supports both IP-based and custom
key-based rate limiting with configurable storage backends.
"""
from __future__ import annotations
from langflow.services.rate_limit.service import get_limiter_key, get_rate_limit_string, get_rate_limiter
__all__ = ["get_limiter_key", "get_rate_limit_string", "get_rate_limiter"]

View File

@ -0,0 +1,104 @@
"""Rate limiting service implementation.
Provides a configured SlowAPI Limiter instance for rate limiting endpoints.
Uses in-memory storage by default, with support for Redis in production.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from slowapi import Limiter
from slowapi.util import get_remote_address
from langflow.services.deps import get_settings_service
if TYPE_CHECKING:
from fastapi import Request
# Global limiter instance
_limiter: Limiter | None = None
def get_rate_limit_string() -> str:
"""Get the rate limit string from settings.
Returns:
str: Rate limit string in format "N/minute", or a very high limit if disabled
"""
settings = get_settings_service().settings
if not settings.rate_limit_enabled:
# Return a very high limit to effectively disable rate limiting
return "10000/minute"
return f"{settings.rate_limit_per_minute}/minute"
def get_rate_limiter() -> Limiter:
"""Get or create the global rate limiter instance.
Returns:
Limiter: Configured SlowAPI Limiter instance
"""
global _limiter # noqa: PLW0603
if _limiter is None:
settings = get_settings_service().settings
# Choose key function based on proxy configuration
# When behind trusted proxies (load balancer, reverse proxy), use X-Forwarded-For
# Otherwise use direct connection IP to prevent header spoofing
key_func = get_client_ip if settings.rate_limit_trust_proxy else get_remote_address
_limiter = Limiter(
key_func=key_func,
storage_uri=settings.rate_limit_storage_uri,
# Don't swallow errors - we want rate limit violations to raise exceptions
swallow_errors=False,
)
return _limiter
def get_client_ip(request: Request) -> str:
"""Extract client IP address from request, using rightmost X-Forwarded-For entry.
When behind a trusted proxy, the rightmost IP in X-Forwarded-For is the last hop
before our server (the trusted proxy itself). The leftmost IP is client-supplied
and can be spoofed.
Args:
request: FastAPI Request object
Returns:
str: Client IP address
"""
# Check X-Forwarded-For header first (for proxied requests)
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
# X-Forwarded-For format: "client, proxy1, proxy2"
# Take the rightmost IP (last proxy before us)
ips = [ip.strip() for ip in forwarded_for.split(",")]
return ips[-1]
# Fall back to direct client IP
if request.client:
return request.client.host
return "unknown"
def get_limiter_key(request: Request) -> str:
"""Get the key that the limiter will actually use for this request.
This matches the limiter's key_func to ensure logging uses the same IP.
Args:
request: FastAPI Request object
Returns:
str: The IP address or key used for rate limiting
"""
settings = get_settings_service().settings
if settings.rate_limit_trust_proxy:
return get_client_ip(request)
return get_remote_address(request)

View File

@ -19,6 +19,7 @@ maintainers = [
dependencies = [
"lfx~=0.5.0",
"fastapi>=0.135.0,<1.0.0",
"slowapi>=0.1.9,<1.0.0",
"httpx[http2]>=0.27,<1.0.0",
"aiofile>=3.9.0,<4.0.0",
"uvicorn>=0.30.0,<1.0.0",

View File

@ -1,5 +1,6 @@
import asyncio
import json
import os
import shutil
# we need to import tmpdir
@ -41,6 +42,14 @@ from tests.api_keys import get_openai_api_key
load_dotenv()
@pytest.fixture(scope="session", autouse=True)
def disable_rate_limiting():
"""Disable rate limiting for all tests to prevent 429 errors during test execution."""
os.environ["LANGFLOW_RATE_LIMIT_ENABLED"] = "false"
yield
os.environ.pop("LANGFLOW_RATE_LIMIT_ENABLED", None)
# TODO: Revert this to True once bb.functions[func].can_block_in("http/client.py", "_safe_read") is fixed
@pytest.fixture(autouse=False)
def blockbuster(request):

View File

@ -0,0 +1,244 @@
"""Tests for login endpoint rate limiting functionality."""
from __future__ import annotations
from unittest.mock import Mock
import pytest
@pytest.fixture
def enable_rate_limiting(monkeypatch):
"""Enable rate limiting for tests that need to verify rate limit behavior."""
monkeypatch.setenv("LANGFLOW_RATE_LIMIT_ENABLED", "true")
@pytest.fixture
def limiter_snapshot():
"""Fixture to snapshot and restore the global limiter singleton."""
import langflow.services.rate_limit.service as rate_limit_module
original_limiter = rate_limit_module._limiter
# Force recreation of limiter for each test to ensure clean state
rate_limit_module._limiter = None
yield
rate_limit_module._limiter = original_limiter
class TestRateLimitService:
"""Test suite for rate limit service configuration."""
def test_rate_limiter_is_configured(self, enable_rate_limiting): # noqa: ARG002
"""Test that rate limiter singleton is properly configured."""
from langflow.services.rate_limit import get_rate_limiter
limiter = get_rate_limiter()
assert limiter is not None
assert limiter.enabled is True
assert limiter._storage_uri == "memory://" # Default storage
assert limiter._swallow_errors is False # Raise exceptions on rate limit
def test_rate_limiter_is_singleton(self, enable_rate_limiting): # noqa: ARG002
"""Test that get_rate_limiter returns the same instance."""
from langflow.services.rate_limit import get_rate_limiter
limiter1 = get_rate_limiter()
limiter2 = get_rate_limiter()
assert limiter1 is limiter2
def test_rate_limit_string_default(self, enable_rate_limiting): # noqa: ARG002
"""Test that default rate limit string is correct."""
from langflow.services.rate_limit.service import get_rate_limit_string
rate_limit = get_rate_limit_string()
assert rate_limit == "5/minute"
def test_rate_limiter_uses_remote_address_by_default(self, enable_rate_limiting): # noqa: ARG002
"""Test that rate limiter uses get_remote_address when trust_proxy is false."""
from langflow.services.rate_limit import get_rate_limiter
from slowapi.util import get_remote_address
limiter = get_rate_limiter()
# Default should use get_remote_address (not trust proxy)
assert limiter._key_func == get_remote_address
def test_rate_limiter_uses_client_ip_when_trust_proxy_enabled(
self,
enable_rate_limiting, # noqa: ARG002
limiter_snapshot, # noqa: ARG002
monkeypatch,
):
"""Test that rate limiter uses get_client_ip when trust_proxy is true."""
# Mock settings to enable trust_proxy
from unittest.mock import MagicMock
from langflow.services.rate_limit.service import get_client_ip
mock_settings = MagicMock()
mock_settings.rate_limit_trust_proxy = True
mock_settings.rate_limit_storage_uri = "memory://"
mock_settings_service = MagicMock()
mock_settings_service.settings = mock_settings
monkeypatch.setattr("langflow.services.rate_limit.service.get_settings_service", lambda: mock_settings_service)
from langflow.services.rate_limit import get_rate_limiter
limiter = get_rate_limiter()
# Should use get_client_ip when trust_proxy is enabled
assert limiter._key_func == get_client_ip
class TestIPExtraction:
"""Test suite for IP address extraction logic."""
def test_get_client_ip_from_x_forwarded_for_single(self):
"""Test IP extraction from X-Forwarded-For with single IP."""
from langflow.services.rate_limit.service import get_client_ip
request = Mock()
request.headers = {"X-Forwarded-For": "203.0.113.1"}
request.client = Mock(host="127.0.0.1")
ip = get_client_ip(request)
assert ip == "203.0.113.1"
def test_get_client_ip_from_x_forwarded_for_chain_uses_rightmost(self):
"""Test IP extraction from X-Forwarded-For uses rightmost IP (trusted proxy)."""
from langflow.services.rate_limit.service import get_client_ip
request = Mock()
request.headers = {"X-Forwarded-For": "203.0.113.1, 198.51.100.1, 192.0.2.1"}
request.client = Mock(host="127.0.0.1")
ip = get_client_ip(request)
# Should use rightmost IP (the trusted proxy before us)
assert ip == "192.0.2.1"
def test_get_client_ip_from_x_forwarded_for_with_spaces(self):
"""Test IP extraction handles spaces in X-Forwarded-For."""
from langflow.services.rate_limit.service import get_client_ip
request = Mock()
request.headers = {"X-Forwarded-For": " 203.0.113.1 , 198.51.100.1 "}
request.client = Mock(host="127.0.0.1")
ip = get_client_ip(request)
# Should use rightmost IP, stripped of whitespace
assert ip == "198.51.100.1"
def test_get_client_ip_from_direct_connection(self):
"""Test IP extraction from direct client connection (no proxy)."""
from langflow.services.rate_limit.service import get_client_ip
request = Mock()
request.headers = {}
request.client = Mock(host="192.168.1.100")
ip = get_client_ip(request)
assert ip == "192.168.1.100"
def test_get_client_ip_fallback_to_unknown(self):
"""Test IP extraction returns 'unknown' when no client info available."""
from langflow.services.rate_limit.service import get_client_ip
request = Mock()
request.headers = {}
request.client = None
ip = get_client_ip(request)
assert ip == "unknown"
def test_get_client_ip_prefers_x_forwarded_for(self):
"""Test that X-Forwarded-For takes precedence over direct client IP."""
from langflow.services.rate_limit.service import get_client_ip
request = Mock()
request.headers = {"X-Forwarded-For": "203.0.113.1"}
request.client = Mock(host="127.0.0.1")
ip = get_client_ip(request)
# Should use X-Forwarded-For, not client.host
assert ip == "203.0.113.1"
assert ip != "127.0.0.1"
class TestRateLimitIntegration:
"""Integration tests verifying rate limiting is applied to login endpoint."""
def test_rate_limit_enforcement_returns_429(self, enable_rate_limiting, limiter_snapshot, active_user): # noqa: ARG002
"""Test that exceeding rate limit returns 429 status code.
Uses TestClient (synchronous) instead of AsyncClient to properly test SlowAPI rate limiting.
Requires limiter_snapshot fixture to ensure clean state and avoid interference from other tests.
"""
from fastapi.testclient import TestClient
from langflow.main import create_app
# Create a fresh app instance for this test
app = create_app()
sync_client = TestClient(app)
# Make 5 requests (the default limit)
status_codes = []
for i in range(5):
response = sync_client.post(
"/api/v1/login",
data={"username": active_user.username, "password": "testpassword"}, # pragma: allowlist secret
)
status_codes.append(response.status_code)
assert response.status_code == 200, f"Request {i + 1} should succeed, got {response.status_code}"
# 6th request should be rate limited
response = sync_client.post(
"/api/v1/login",
data={"username": active_user.username, "password": "testpassword"}, # pragma: allowlist secret
)
status_codes.append(response.status_code)
assert response.status_code == 429, f"Expected 429, got {response.status_code}. All codes: {status_codes}"
response_detail = response.json()["detail"].lower()
assert "too many requests" in response_detail or "rate limit" in response_detail
@pytest.mark.asyncio
async def test_login_endpoint_has_rate_limiter_applied(self, enable_rate_limiting): # noqa: ARG002
"""Test that the login endpoint has rate limiting applied via app.state.limiter."""
from langflow.api.v1.login import get_limiter_from_app, login_to_get_access_token
from langflow.main import create_app
# Create app to ensure limiter is attached to app.state
app = create_app()
# Verify limiter is attached to app.state
assert hasattr(app.state, "limiter")
assert app.state.limiter is not None
assert app.state.limiter.enabled is True
# Verify the endpoint function exists
assert login_to_get_access_token is not None
# Verify get_limiter_from_app helper exists
assert get_limiter_from_app is not None
@pytest.mark.asyncio
async def test_successful_login_within_reasonable_limit(self, enable_rate_limiting, client, active_user): # noqa: ARG002
"""Test that a single login request succeeds (well within any rate limit)."""
response = await client.post(
"/api/v1/login",
data={"username": active_user.username, "password": "testpassword"}, # pragma: allowlist secret
headers={"X-Forwarded-For": "10.0.0.1"}, # Unique IP to avoid conflicts
)
assert response.status_code == 200
assert "access_token" in response.json()

View File

@ -255,6 +255,16 @@ class Settings(BaseSettings):
redis_url: str | None = None
redis_cache_expire: int = 3600
# Rate Limiting
rate_limit_enabled: bool = True
"""Enable rate limiting for login endpoint. Set to False to disable (useful for testing)."""
rate_limit_per_minute: int = 5
"""Number of login attempts allowed per minute per IP."""
rate_limit_storage_uri: str = "memory://"
"""Storage backend for rate limiting. Use 'memory://' for single-server or 'redis://host:port' for multi-server."""
rate_limit_trust_proxy: bool = False
"""Trust X-Forwarded-For header when behind a reverse proxy. Only enable when behind a trusted proxy."""
# Job Queue
job_queue_type: Literal["asyncio", "redis"] = "asyncio"
"""The job queue backend. Use 'redis' for multi-worker deployments to solve cross-worker JobQueueNotFoundError."""

28
uv.lock generated
View File

@ -7675,6 +7675,7 @@ dependencies = [
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "sentry-sdk", extra = ["fastapi", "loguru"] },
{ name = "setuptools" },
{ name = "slowapi" },
{ name = "spider-client" },
{ name = "sqlalchemy", extra = ["aiosqlite"] },
{ name = "sqlmodel" },
@ -8595,6 +8596,7 @@ requires-dist = [
{ name = "sentence-transformers", marker = "extra == 'sentence-transformers'", specifier = ">=2.3.1" },
{ name = "sentry-sdk", extras = ["fastapi", "loguru"], specifier = ">=2.5.1,<3.0.0" },
{ name = "setuptools", specifier = ">=80.0.0,<81.0.0" },
{ name = "slowapi", specifier = ">=0.1.9,<1.0.0" },
{ name = "smolagents", marker = "extra == 'smolagents'", specifier = ">=1.8.0" },
{ name = "spider-client", specifier = ">=0.0.27,<1.0.0" },
{ name = "spider-client", marker = "extra == 'spider'", specifier = ">=0.0.27,<1.0.0" },
@ -9345,6 +9347,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
]
[[package]]
name = "limits"
version = "5.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deprecated" },
{ name = "packaging" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" },
]
[[package]]
name = "line-profiler"
version = "5.0.2"
@ -17127,6 +17143,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "slowapi"
version = "0.1.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "limits" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" },
]
[[package]]
name = "smmap"
version = "5.0.3"