diff --git a/.env.example b/.env.example index d767810470..c8c6d425f8 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.secrets.baseline b/.secrets.baseline index b12441041c..e6122353e6 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -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" } diff --git a/src/backend/base/langflow/api/v1/login.py b/src/backend/base/langflow/api/v1/login.py index 17f2105a0f..d74ec9c2e8 100644 --- a/src/backend/base/langflow/api/v1/login.py +++ b/src/backend/base/langflow/api/v1/login.py @@ -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() diff --git a/src/backend/base/langflow/main.py b/src/backend/base/langflow/main.py index 02567f5b2e..ff90a3db74 100644 --- a/src/backend/base/langflow/main.py +++ b/src/backend/base/langflow/main.py @@ -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 diff --git a/src/backend/base/langflow/services/rate_limit/__init__.py b/src/backend/base/langflow/services/rate_limit/__init__.py new file mode 100644 index 0000000000..d57d5d5440 --- /dev/null +++ b/src/backend/base/langflow/services/rate_limit/__init__.py @@ -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"] diff --git a/src/backend/base/langflow/services/rate_limit/service.py b/src/backend/base/langflow/services/rate_limit/service.py new file mode 100644 index 0000000000..b93cd424fa --- /dev/null +++ b/src/backend/base/langflow/services/rate_limit/service.py @@ -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) diff --git a/src/backend/base/pyproject.toml b/src/backend/base/pyproject.toml index 86a2f1f7c2..90dc9d9633 100644 --- a/src/backend/base/pyproject.toml +++ b/src/backend/base/pyproject.toml @@ -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", diff --git a/src/backend/tests/conftest.py b/src/backend/tests/conftest.py index f7f6362d41..2e549ff9b1 100644 --- a/src/backend/tests/conftest.py +++ b/src/backend/tests/conftest.py @@ -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): diff --git a/src/backend/tests/unit/test_login_rate_limiting.py b/src/backend/tests/unit/test_login_rate_limiting.py new file mode 100644 index 0000000000..cfb71730ec --- /dev/null +++ b/src/backend/tests/unit/test_login_rate_limiting.py @@ -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() diff --git a/src/lfx/src/lfx/services/settings/base.py b/src/lfx/src/lfx/services/settings/base.py index 76afcc6312..c102375602 100644 --- a/src/lfx/src/lfx/services/settings/base.py +++ b/src/lfx/src/lfx/services/settings/base.py @@ -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.""" diff --git a/uv.lock b/uv.lock index 07eef08ac7..d764718e35 100644 --- a/uv.lock +++ b/uv.lock @@ -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"