feat: add configuration loader for .env and JSON files
- Add ConfigLoader class for loading RateLimitConfig and GlobalConfig - Support .env files with FASTAPI_TRAFFIC_* prefixed variables - Support JSON configuration files with type validation - Add convenience functions: load_rate_limit_config, load_global_config - Add load_rate_limit_config_from_env, load_global_config_from_env - Support custom environment variable prefixes - Add comprehensive error handling with ConfigurationError - Add 47 tests for configuration loading - Add example 11_config_loader.py with 9 usage patterns - Update examples/README.md with config loader documentation - Update CHANGELOG.md with new feature - Fix typo in limiter.py (errant 'fi' on line 4)
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Configuration Loader** - Load rate limiting configuration from external files:
|
||||||
|
- `ConfigLoader` class for loading `RateLimitConfig` and `GlobalConfig`
|
||||||
|
- Support for `.env` files with `FASTAPI_TRAFFIC_*` prefixed variables
|
||||||
|
- Support for JSON configuration files
|
||||||
|
- Environment variable loading with `load_rate_limit_config_from_env()` and `load_global_config_from_env()`
|
||||||
|
- Auto-detection of file format with `load_rate_limit_config()` and `load_global_config()`
|
||||||
|
- Custom environment variable prefix support
|
||||||
|
- Type validation and comprehensive error handling
|
||||||
|
- 47 new tests for configuration loading
|
||||||
|
- Example `11_config_loader.py` demonstrating all configuration loading patterns
|
||||||
- `get_stats()` method to `MemoryBackend` for consistency with `RedisBackend`
|
- `get_stats()` method to `MemoryBackend` for consistency with `RedisBackend`
|
||||||
- Comprehensive test suite with 134 tests covering:
|
- Comprehensive test suite with 134 tests covering:
|
||||||
- All five rate limiting algorithms with timing and concurrency tests
|
- All five rate limiting algorithms with timing and concurrency tests
|
||||||
|
|||||||
441
examples/11_config_loader.py
Normal file
441
examples/11_config_loader.py
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
"""Examples demonstrating configuration loading from .env and JSON files.
|
||||||
|
|
||||||
|
This module shows how to load rate limiting configuration from external files,
|
||||||
|
making it easy to manage settings across different environments (dev, staging, prod).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from fastapi_traffic import (
|
||||||
|
ConfigLoader,
|
||||||
|
MemoryBackend,
|
||||||
|
RateLimiter,
|
||||||
|
RateLimitExceeded,
|
||||||
|
load_global_config,
|
||||||
|
load_global_config_from_env,
|
||||||
|
load_rate_limit_config,
|
||||||
|
load_rate_limit_config_from_env,
|
||||||
|
rate_limit,
|
||||||
|
)
|
||||||
|
from fastapi_traffic.core.config import GlobalConfig, RateLimitConfig
|
||||||
|
from fastapi_traffic.core.limiter import set_limiter
|
||||||
|
from fastapi_traffic.exceptions import ConfigurationError
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Example 1: Loading RateLimitConfig from environment variables
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def example_env_variables() -> RateLimitConfig:
|
||||||
|
"""Load rate limit config from environment variables.
|
||||||
|
|
||||||
|
Set these environment variables before running:
|
||||||
|
export FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100
|
||||||
|
export FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE=60.0
|
||||||
|
export FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM=sliding_window_counter
|
||||||
|
export FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX=myapi
|
||||||
|
"""
|
||||||
|
# Using the convenience function
|
||||||
|
config = load_rate_limit_config_from_env(
|
||||||
|
# You can provide overrides for values not in env vars
|
||||||
|
limit=50, # Default if FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT not set
|
||||||
|
)
|
||||||
|
print(f"Loaded config: limit={config.limit}, window={config.window_size}s")
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Example 2: Loading GlobalConfig from environment variables
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def example_global_config_env() -> GlobalConfig:
|
||||||
|
"""Load global config from environment variables.
|
||||||
|
|
||||||
|
Set these environment variables:
|
||||||
|
export FASTAPI_TRAFFIC_GLOBAL_ENABLED=true
|
||||||
|
export FASTAPI_TRAFFIC_GLOBAL_DEFAULT_LIMIT=200
|
||||||
|
export FASTAPI_TRAFFIC_GLOBAL_DEFAULT_WINDOW_SIZE=120.0
|
||||||
|
export FASTAPI_TRAFFIC_GLOBAL_EXEMPT_IPS=127.0.0.1,10.0.0.1
|
||||||
|
export FASTAPI_TRAFFIC_GLOBAL_EXEMPT_PATHS=/health,/metrics
|
||||||
|
"""
|
||||||
|
config = load_global_config_from_env()
|
||||||
|
print(f"Global config: enabled={config.enabled}, limit={config.default_limit}")
|
||||||
|
print(f"Exempt IPs: {config.exempt_ips}")
|
||||||
|
print(f"Exempt paths: {config.exempt_paths}")
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Example 3: Loading from .env file
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def example_dotenv_file() -> RateLimitConfig:
|
||||||
|
"""Load rate limit config from a .env file.
|
||||||
|
|
||||||
|
Example .env file contents:
|
||||||
|
# Rate limiting configuration
|
||||||
|
FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100
|
||||||
|
FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE=60.0
|
||||||
|
FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM=token_bucket
|
||||||
|
FASTAPI_TRAFFIC_RATE_LIMIT_BURST_SIZE=20
|
||||||
|
FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX=api_v1
|
||||||
|
FASTAPI_TRAFFIC_RATE_LIMIT_INCLUDE_HEADERS=true
|
||||||
|
FASTAPI_TRAFFIC_RATE_LIMIT_ERROR_MESSAGE="Too many requests, please slow down"
|
||||||
|
"""
|
||||||
|
# Create a sample .env file for demonstration
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
|
||||||
|
f.write("# Rate limit configuration\n")
|
||||||
|
f.write("FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100\n")
|
||||||
|
f.write("FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE=60.0\n")
|
||||||
|
f.write("FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM=token_bucket\n")
|
||||||
|
f.write("FASTAPI_TRAFFIC_RATE_LIMIT_BURST_SIZE=20\n")
|
||||||
|
f.write('FASTAPI_TRAFFIC_RATE_LIMIT_ERROR_MESSAGE="Rate limit exceeded"\n')
|
||||||
|
env_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load using auto-detection (detects .env suffix)
|
||||||
|
config = load_rate_limit_config(env_path)
|
||||||
|
print(f"From .env: limit={config.limit}, algorithm={config.algorithm}")
|
||||||
|
print(f"Burst size: {config.burst_size}")
|
||||||
|
return config
|
||||||
|
finally:
|
||||||
|
Path(env_path).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Example 4: Loading from JSON file
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def example_json_file() -> RateLimitConfig:
|
||||||
|
"""Load rate limit config from a JSON file.
|
||||||
|
|
||||||
|
Example config.json:
|
||||||
|
{
|
||||||
|
"limit": 500,
|
||||||
|
"window_size": 300.0,
|
||||||
|
"algorithm": "sliding_window_counter",
|
||||||
|
"key_prefix": "production",
|
||||||
|
"include_headers": true,
|
||||||
|
"status_code": 429,
|
||||||
|
"cost": 1
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# Create a sample JSON file for demonstration
|
||||||
|
config_data = {
|
||||||
|
"limit": 500,
|
||||||
|
"window_size": 300.0,
|
||||||
|
"algorithm": "sliding_window_counter",
|
||||||
|
"key_prefix": "production",
|
||||||
|
"include_headers": True,
|
||||||
|
"status_code": 429,
|
||||||
|
"skip_on_error": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
json.dump(config_data, f, indent=2)
|
||||||
|
json_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load using auto-detection (detects .json suffix)
|
||||||
|
config = load_rate_limit_config(json_path)
|
||||||
|
print(f"From JSON: limit={config.limit}, window={config.window_size}s")
|
||||||
|
print(f"Algorithm: {config.algorithm.value}")
|
||||||
|
return config
|
||||||
|
finally:
|
||||||
|
Path(json_path).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Example 5: Loading GlobalConfig from JSON
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def example_global_config_json() -> GlobalConfig:
|
||||||
|
"""Load global config from a JSON file.
|
||||||
|
|
||||||
|
Example global_config.json:
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"default_limit": 1000,
|
||||||
|
"default_window_size": 60.0,
|
||||||
|
"default_algorithm": "sliding_window_counter",
|
||||||
|
"key_prefix": "myapp",
|
||||||
|
"include_headers": true,
|
||||||
|
"exempt_ips": ["127.0.0.1", "::1", "10.0.0.0/8"],
|
||||||
|
"exempt_paths": ["/health", "/ready", "/metrics", "/docs"]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
config_data = {
|
||||||
|
"enabled": True,
|
||||||
|
"default_limit": 1000,
|
||||||
|
"default_window_size": 60.0,
|
||||||
|
"default_algorithm": "sliding_window_counter",
|
||||||
|
"key_prefix": "myapp",
|
||||||
|
"exempt_ips": ["127.0.0.1", "::1"],
|
||||||
|
"exempt_paths": ["/health", "/ready", "/metrics"],
|
||||||
|
}
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
json.dump(config_data, f, indent=2)
|
||||||
|
json_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = load_global_config(json_path)
|
||||||
|
print(f"Global: enabled={config.enabled}, limit={config.default_limit}")
|
||||||
|
print(f"Exempt paths: {config.exempt_paths}")
|
||||||
|
return config
|
||||||
|
finally:
|
||||||
|
Path(json_path).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Example 6: Using ConfigLoader class with custom prefix
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def example_custom_prefix() -> RateLimitConfig:
|
||||||
|
"""Use ConfigLoader with a custom environment variable prefix.
|
||||||
|
|
||||||
|
Useful when you want to namespace your config variables differently,
|
||||||
|
e.g., for different services or to avoid conflicts.
|
||||||
|
"""
|
||||||
|
# Create a loader with custom prefix
|
||||||
|
loader = ConfigLoader(env_prefix="MYAPP_RATELIMIT_")
|
||||||
|
|
||||||
|
# Simulated environment variables with custom prefix
|
||||||
|
env_vars = {
|
||||||
|
"MYAPP_RATELIMIT_RATE_LIMIT_LIMIT": "250",
|
||||||
|
"MYAPP_RATELIMIT_RATE_LIMIT_WINDOW_SIZE": "30.0",
|
||||||
|
"MYAPP_RATELIMIT_RATE_LIMIT_ALGORITHM": "fixed_window",
|
||||||
|
}
|
||||||
|
|
||||||
|
config = loader.load_rate_limit_config_from_env(env_vars)
|
||||||
|
print(f"Custom prefix: limit={config.limit}, algorithm={config.algorithm}")
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Example 7: Validation and error handling
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def example_validation() -> None:
|
||||||
|
"""Demonstrate configuration validation and error handling."""
|
||||||
|
loader = ConfigLoader()
|
||||||
|
|
||||||
|
# Example 1: Invalid algorithm value
|
||||||
|
try:
|
||||||
|
loader.load_rate_limit_config_from_env(
|
||||||
|
{
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100",
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM": "invalid_algo",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ConfigurationError as e:
|
||||||
|
print(f"Validation error (invalid algorithm): {e}")
|
||||||
|
|
||||||
|
# Example 2: Invalid numeric value
|
||||||
|
try:
|
||||||
|
loader.load_rate_limit_config_from_env(
|
||||||
|
{"FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "not_a_number"}
|
||||||
|
)
|
||||||
|
except ConfigurationError as e:
|
||||||
|
print(f"Validation error (invalid number): {e}")
|
||||||
|
|
||||||
|
# Example 3: Missing required field
|
||||||
|
try:
|
||||||
|
loader.load_rate_limit_config_from_env(
|
||||||
|
{"FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE": "60.0"}
|
||||||
|
)
|
||||||
|
except ConfigurationError as e:
|
||||||
|
print(f"Validation error (missing limit): {e}")
|
||||||
|
|
||||||
|
# Example 4: Non-loadable field (callables can't be loaded from config)
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
json.dump({"limit": 100, "key_extractor": "some_function"}, f)
|
||||||
|
json_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
loader.load_rate_limit_config_from_json(json_path)
|
||||||
|
except ConfigurationError as e:
|
||||||
|
print(f"Validation error (non-loadable field): {e}")
|
||||||
|
finally:
|
||||||
|
Path(json_path).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Example 8: Environment-based configuration (dev/staging/prod)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def example_environment_based_config() -> RateLimitConfig:
|
||||||
|
"""Load different configurations based on environment.
|
||||||
|
|
||||||
|
This pattern is useful for having different rate limits in
|
||||||
|
development, staging, and production environments.
|
||||||
|
"""
|
||||||
|
env = os.getenv("APP_ENV", "development")
|
||||||
|
|
||||||
|
# In a real app, these would be actual files
|
||||||
|
configs = {
|
||||||
|
"development": {"limit": 1000, "window_size": 60.0, "skip_on_error": True},
|
||||||
|
"staging": {"limit": 500, "window_size": 60.0, "skip_on_error": True},
|
||||||
|
"production": {"limit": 100, "window_size": 60.0, "skip_on_error": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
config_data = configs.get(env, configs["development"])
|
||||||
|
|
||||||
|
# Create temp file with the config
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
json.dump(config_data, f)
|
||||||
|
config_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = load_rate_limit_config(config_path)
|
||||||
|
print(f"Environment '{env}': limit={config.limit}")
|
||||||
|
return config
|
||||||
|
finally:
|
||||||
|
Path(config_path).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Example 9: Full FastAPI application with config loading
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def create_app_with_config() -> FastAPI:
|
||||||
|
"""Create a FastAPI app with configuration loaded from files."""
|
||||||
|
# In production, load from actual config files:
|
||||||
|
# global_config = load_global_config("config/global.json")
|
||||||
|
# rate_config = load_rate_limit_config("config/rate_limit.json")
|
||||||
|
|
||||||
|
# For this example, create inline configs
|
||||||
|
global_config = GlobalConfig(
|
||||||
|
enabled=True,
|
||||||
|
default_limit=100,
|
||||||
|
default_window_size=60.0,
|
||||||
|
exempt_paths={"/health", "/docs", "/openapi.json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = MemoryBackend()
|
||||||
|
limiter = RateLimiter(backend, config=global_config)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI):
|
||||||
|
await limiter.initialize()
|
||||||
|
set_limiter(limiter)
|
||||||
|
yield
|
||||||
|
await limiter.close()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Config Loader Example",
|
||||||
|
description="Rate limiting with external configuration",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(RateLimitExceeded)
|
||||||
|
async def rate_limit_handler(_: Request, exc: RateLimitExceeded) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={
|
||||||
|
"error": "rate_limit_exceeded",
|
||||||
|
"message": exc.message,
|
||||||
|
"retry_after": exc.retry_after,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
@rate_limit(limit=10, window_size=60)
|
||||||
|
async def root(_: Request) -> dict[str, str]:
|
||||||
|
return {"message": "Hello from config-loaded app!"}
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
"""Health check - exempt from rate limiting."""
|
||||||
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
@app.get("/api/data")
|
||||||
|
@rate_limit(limit=50, window_size=60)
|
||||||
|
async def get_data(_: Request) -> dict[str, str]:
|
||||||
|
return {"data": "Some API data"}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# Create the app instance
|
||||||
|
app = create_app_with_config()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Run all examples
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def run_examples() -> None:
|
||||||
|
"""Run all configuration loading examples."""
|
||||||
|
print("=" * 60)
|
||||||
|
print("FastAPI Traffic - Configuration Loader Examples")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
print("\n1. Loading from environment variables:")
|
||||||
|
print("-" * 40)
|
||||||
|
example_env_variables()
|
||||||
|
|
||||||
|
print("\n2. Loading GlobalConfig from environment:")
|
||||||
|
print("-" * 40)
|
||||||
|
example_global_config_env()
|
||||||
|
|
||||||
|
print("\n3. Loading from .env file:")
|
||||||
|
print("-" * 40)
|
||||||
|
example_dotenv_file()
|
||||||
|
|
||||||
|
print("\n4. Loading from JSON file:")
|
||||||
|
print("-" * 40)
|
||||||
|
example_json_file()
|
||||||
|
|
||||||
|
print("\n5. Loading GlobalConfig from JSON:")
|
||||||
|
print("-" * 40)
|
||||||
|
example_global_config_json()
|
||||||
|
|
||||||
|
print("\n6. Using custom environment prefix:")
|
||||||
|
print("-" * 40)
|
||||||
|
example_custom_prefix()
|
||||||
|
|
||||||
|
print("\n7. Validation and error handling:")
|
||||||
|
print("-" * 40)
|
||||||
|
example_validation()
|
||||||
|
|
||||||
|
print("\n8. Environment-based configuration:")
|
||||||
|
print("-" * 40)
|
||||||
|
example_environment_based_config()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("All examples completed!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "--demo":
|
||||||
|
# Run the demo examples
|
||||||
|
run_examples()
|
||||||
|
else:
|
||||||
|
# Run the FastAPI app
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
print("Starting FastAPI app with config loader...")
|
||||||
|
print("Run with --demo flag to see configuration examples")
|
||||||
|
uvicorn.run(app, host="127.0.0.1", port=8011)
|
||||||
@@ -5,13 +5,17 @@ This directory contains comprehensive examples demonstrating how to use the `fas
|
|||||||
## Basic Examples
|
## Basic Examples
|
||||||
|
|
||||||
### 01_quickstart.py
|
### 01_quickstart.py
|
||||||
|
|
||||||
Minimal setup to get rate limiting working. Start here if you're new to the library.
|
Minimal setup to get rate limiting working. Start here if you're new to the library.
|
||||||
|
|
||||||
- Basic backend and limiter setup
|
- Basic backend and limiter setup
|
||||||
- Exception handler for rate limit errors
|
- Exception handler for rate limit errors
|
||||||
- Simple decorator usage
|
- Simple decorator usage
|
||||||
|
|
||||||
### 02_algorithms.py
|
### 02_algorithms.py
|
||||||
|
|
||||||
Demonstrates all available rate limiting algorithms:
|
Demonstrates all available rate limiting algorithms:
|
||||||
|
|
||||||
- **Fixed Window** - Simple, resets at fixed intervals
|
- **Fixed Window** - Simple, resets at fixed intervals
|
||||||
- **Sliding Window** - Most precise, stores timestamps
|
- **Sliding Window** - Most precise, stores timestamps
|
||||||
- **Sliding Window Counter** - Balance of precision and efficiency (default)
|
- **Sliding Window Counter** - Balance of precision and efficiency (default)
|
||||||
@@ -19,13 +23,17 @@ Demonstrates all available rate limiting algorithms:
|
|||||||
- **Leaky Bucket** - Smooths out traffic
|
- **Leaky Bucket** - Smooths out traffic
|
||||||
|
|
||||||
### 03_backends.py
|
### 03_backends.py
|
||||||
|
|
||||||
Shows different storage backends:
|
Shows different storage backends:
|
||||||
|
|
||||||
- **MemoryBackend** - Fast, ephemeral (default)
|
- **MemoryBackend** - Fast, ephemeral (default)
|
||||||
- **SQLiteBackend** - Persistent, single-instance
|
- **SQLiteBackend** - Persistent, single-instance
|
||||||
- **RedisBackend** - Distributed, multi-instance
|
- **RedisBackend** - Distributed, multi-instance
|
||||||
|
|
||||||
### 04_key_extractors.py
|
### 04_key_extractors.py
|
||||||
|
|
||||||
Custom key extractors for different rate limiting strategies:
|
Custom key extractors for different rate limiting strategies:
|
||||||
|
|
||||||
- Rate limit by IP address (default)
|
- Rate limit by IP address (default)
|
||||||
- Rate limit by API key
|
- Rate limit by API key
|
||||||
- Rate limit by user ID
|
- Rate limit by user ID
|
||||||
@@ -34,7 +42,9 @@ Custom key extractors for different rate limiting strategies:
|
|||||||
- Composite keys (user + action)
|
- Composite keys (user + action)
|
||||||
|
|
||||||
### 05_middleware.py
|
### 05_middleware.py
|
||||||
|
|
||||||
Middleware-based rate limiting for global protection:
|
Middleware-based rate limiting for global protection:
|
||||||
|
|
||||||
- Basic middleware setup
|
- Basic middleware setup
|
||||||
- Custom configuration options
|
- Custom configuration options
|
||||||
- Path and IP exemptions
|
- Path and IP exemptions
|
||||||
@@ -43,35 +53,45 @@ Middleware-based rate limiting for global protection:
|
|||||||
## Advanced Examples
|
## Advanced Examples
|
||||||
|
|
||||||
### 06_dependency_injection.py
|
### 06_dependency_injection.py
|
||||||
|
|
||||||
Using FastAPI's dependency injection system:
|
Using FastAPI's dependency injection system:
|
||||||
|
|
||||||
- Basic rate limit dependency
|
- Basic rate limit dependency
|
||||||
- Tier-based rate limiting
|
- Tier-based rate limiting
|
||||||
- Combining multiple rate limits
|
- Combining multiple rate limits
|
||||||
- Conditional exemptions
|
- Conditional exemptions
|
||||||
|
|
||||||
### 07_redis_distributed.py
|
### 07_redis_distributed.py
|
||||||
|
|
||||||
Redis backend for distributed deployments:
|
Redis backend for distributed deployments:
|
||||||
|
|
||||||
- Multi-instance rate limiting
|
- Multi-instance rate limiting
|
||||||
- Shared counters across nodes
|
- Shared counters across nodes
|
||||||
- Health checks and statistics
|
- Health checks and statistics
|
||||||
- Fallback to memory backend
|
- Fallback to memory backend
|
||||||
|
|
||||||
### 08_tiered_api.py
|
### 08_tiered_api.py
|
||||||
|
|
||||||
Production-ready tiered API example:
|
Production-ready tiered API example:
|
||||||
|
|
||||||
- Free, Starter, Pro, Enterprise tiers
|
- Free, Starter, Pro, Enterprise tiers
|
||||||
- Different limits per tier
|
- Different limits per tier
|
||||||
- Feature gating based on tier
|
- Feature gating based on tier
|
||||||
- API key validation
|
- API key validation
|
||||||
|
|
||||||
### 09_custom_responses.py
|
### 09_custom_responses.py
|
||||||
|
|
||||||
Customizing rate limit responses:
|
Customizing rate limit responses:
|
||||||
|
|
||||||
- Custom JSON error responses
|
- Custom JSON error responses
|
||||||
- Logging/monitoring callbacks
|
- Logging/monitoring callbacks
|
||||||
- Different response formats (JSON, HTML, plain text)
|
- Different response formats (JSON, HTML, plain text)
|
||||||
- Rate limit headers
|
- Rate limit headers
|
||||||
|
|
||||||
### 10_advanced_patterns.py
|
### 10_advanced_patterns.py
|
||||||
|
|
||||||
Real-world patterns and use cases:
|
Real-world patterns and use cases:
|
||||||
|
|
||||||
- **Cost-based limiting** - Different operations cost different amounts
|
- **Cost-based limiting** - Different operations cost different amounts
|
||||||
- **Priority exemptions** - Premium users exempt from limits
|
- **Priority exemptions** - Premium users exempt from limits
|
||||||
- **Resource-based limiting** - Limit by resource ID + user
|
- **Resource-based limiting** - Limit by resource ID + user
|
||||||
@@ -81,6 +101,23 @@ Real-world patterns and use cases:
|
|||||||
- **Time-of-day limits** - Peak vs off-peak hours
|
- **Time-of-day limits** - Peak vs off-peak hours
|
||||||
- **Cascading limits** - Per-second, per-minute, per-hour
|
- **Cascading limits** - Per-second, per-minute, per-hour
|
||||||
|
|
||||||
|
### 11_config_loader.py
|
||||||
|
|
||||||
|
Loading configuration from external files:
|
||||||
|
|
||||||
|
- **Environment variables** - Load from `FASTAPI_TRAFFIC_*` env vars
|
||||||
|
- **.env files** - Load from dotenv files for local development
|
||||||
|
- **JSON files** - Load from JSON for structured configuration
|
||||||
|
- **Custom prefixes** - Use custom env var prefixes
|
||||||
|
- **Validation** - Automatic type validation and error handling
|
||||||
|
- **Environment-based config** - Different configs for dev/staging/prod
|
||||||
|
|
||||||
|
Run with `--demo` flag to see all configuration examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python examples/11_config_loader.py --demo
|
||||||
|
```
|
||||||
|
|
||||||
## Running Examples
|
## Running Examples
|
||||||
|
|
||||||
Each example is a standalone FastAPI application. Run with:
|
Each example is a standalone FastAPI application. Run with:
|
||||||
|
|||||||
@@ -1,30 +1,43 @@
|
|||||||
"""FastAPI Traffic - Production-grade rate limiting for FastAPI."""
|
"""FastAPI Traffic - Production-grade rate limiting for FastAPI."""
|
||||||
|
|
||||||
from fastapi_traffic.core.decorator import rate_limit
|
|
||||||
from fastapi_traffic.core.limiter import RateLimiter
|
|
||||||
from fastapi_traffic.core.config import RateLimitConfig
|
|
||||||
from fastapi_traffic.core.algorithms import Algorithm
|
|
||||||
from fastapi_traffic.backends.base import Backend
|
from fastapi_traffic.backends.base import Backend
|
||||||
from fastapi_traffic.backends.memory import MemoryBackend
|
from fastapi_traffic.backends.memory import MemoryBackend
|
||||||
from fastapi_traffic.backends.sqlite import SQLiteBackend
|
from fastapi_traffic.backends.sqlite import SQLiteBackend
|
||||||
|
from fastapi_traffic.core.algorithms import Algorithm
|
||||||
|
from fastapi_traffic.core.config import GlobalConfig, RateLimitConfig
|
||||||
|
from fastapi_traffic.core.config_loader import (
|
||||||
|
ConfigLoader,
|
||||||
|
load_global_config,
|
||||||
|
load_global_config_from_env,
|
||||||
|
load_rate_limit_config,
|
||||||
|
load_rate_limit_config_from_env,
|
||||||
|
)
|
||||||
|
from fastapi_traffic.core.decorator import rate_limit
|
||||||
|
from fastapi_traffic.core.limiter import RateLimiter
|
||||||
from fastapi_traffic.exceptions import (
|
from fastapi_traffic.exceptions import (
|
||||||
RateLimitExceeded,
|
|
||||||
BackendError,
|
BackendError,
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
|
RateLimitExceeded,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"rate_limit",
|
|
||||||
"RateLimiter",
|
|
||||||
"RateLimitConfig",
|
|
||||||
"Algorithm",
|
"Algorithm",
|
||||||
"Backend",
|
"Backend",
|
||||||
"MemoryBackend",
|
|
||||||
"SQLiteBackend",
|
|
||||||
"RateLimitExceeded",
|
|
||||||
"BackendError",
|
"BackendError",
|
||||||
|
"ConfigLoader",
|
||||||
"ConfigurationError",
|
"ConfigurationError",
|
||||||
|
"GlobalConfig",
|
||||||
|
"MemoryBackend",
|
||||||
|
"RateLimitConfig",
|
||||||
|
"RateLimitExceeded",
|
||||||
|
"RateLimiter",
|
||||||
|
"SQLiteBackend",
|
||||||
|
"load_global_config",
|
||||||
|
"load_global_config_from_env",
|
||||||
|
"load_rate_limit_config",
|
||||||
|
"load_rate_limit_config_from_env",
|
||||||
|
"rate_limit",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Optional Redis backend
|
# Optional Redis backend
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
"""Core rate limiting components."""
|
"""Core rate limiting components."""
|
||||||
|
|
||||||
from fastapi_traffic.core.algorithms import Algorithm
|
from fastapi_traffic.core.algorithms import Algorithm
|
||||||
from fastapi_traffic.core.config import RateLimitConfig
|
from fastapi_traffic.core.config import GlobalConfig, RateLimitConfig
|
||||||
|
from fastapi_traffic.core.config_loader import (
|
||||||
|
ConfigLoader,
|
||||||
|
load_global_config,
|
||||||
|
load_global_config_from_env,
|
||||||
|
load_rate_limit_config,
|
||||||
|
load_rate_limit_config_from_env,
|
||||||
|
)
|
||||||
from fastapi_traffic.core.decorator import rate_limit
|
from fastapi_traffic.core.decorator import rate_limit
|
||||||
from fastapi_traffic.core.limiter import RateLimiter
|
from fastapi_traffic.core.limiter import RateLimiter
|
||||||
from fastapi_traffic.core.models import RateLimitInfo, RateLimitResult
|
from fastapi_traffic.core.models import RateLimitInfo, RateLimitResult
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Algorithm",
|
"Algorithm",
|
||||||
|
"ConfigLoader",
|
||||||
|
"GlobalConfig",
|
||||||
"RateLimitConfig",
|
"RateLimitConfig",
|
||||||
"rate_limit",
|
|
||||||
"RateLimiter",
|
|
||||||
"RateLimitInfo",
|
"RateLimitInfo",
|
||||||
"RateLimitResult",
|
"RateLimitResult",
|
||||||
|
"RateLimiter",
|
||||||
|
"load_global_config",
|
||||||
|
"load_global_config_from_env",
|
||||||
|
"load_rate_limit_config",
|
||||||
|
"load_rate_limit_config_from_env",
|
||||||
|
"rate_limit",
|
||||||
]
|
]
|
||||||
|
|||||||
532
fastapi_traffic/core/config_loader.py
Normal file
532
fastapi_traffic/core/config_loader.py
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
"""Configuration loader for rate limiting settings from .env and .json files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, TypeVar
|
||||||
|
|
||||||
|
from fastapi_traffic.core.algorithms import Algorithm
|
||||||
|
from fastapi_traffic.core.config import GlobalConfig, RateLimitConfig
|
||||||
|
from fastapi_traffic.exceptions import ConfigurationError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
T = TypeVar("T", RateLimitConfig, GlobalConfig)
|
||||||
|
|
||||||
|
# Environment variable prefix for config values
|
||||||
|
ENV_PREFIX = "FASTAPI_TRAFFIC_"
|
||||||
|
|
||||||
|
# Mapping of config field names to their types for validation
|
||||||
|
_RATE_LIMIT_FIELD_TYPES: dict[str, type[Any]] = {
|
||||||
|
"limit": int,
|
||||||
|
"window_size": float,
|
||||||
|
"algorithm": Algorithm,
|
||||||
|
"key_prefix": str,
|
||||||
|
"burst_size": int,
|
||||||
|
"include_headers": bool,
|
||||||
|
"error_message": str,
|
||||||
|
"status_code": int,
|
||||||
|
"skip_on_error": bool,
|
||||||
|
"cost": int,
|
||||||
|
}
|
||||||
|
|
||||||
|
_GLOBAL_FIELD_TYPES: dict[str, type[Any]] = {
|
||||||
|
"enabled": bool,
|
||||||
|
"default_limit": int,
|
||||||
|
"default_window_size": float,
|
||||||
|
"default_algorithm": Algorithm,
|
||||||
|
"key_prefix": str,
|
||||||
|
"include_headers": bool,
|
||||||
|
"error_message": str,
|
||||||
|
"status_code": int,
|
||||||
|
"skip_on_error": bool,
|
||||||
|
"exempt_ips": set,
|
||||||
|
"exempt_paths": set,
|
||||||
|
"headers_prefix": str,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fields that cannot be loaded from config files (callables, complex objects)
|
||||||
|
_NON_LOADABLE_FIELDS: frozenset[str] = frozenset({
|
||||||
|
"key_extractor",
|
||||||
|
"exempt_when",
|
||||||
|
"on_blocked",
|
||||||
|
"backend",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigLoader:
|
||||||
|
"""Loader for rate limiting configuration from various sources.
|
||||||
|
|
||||||
|
Supports loading configuration from:
|
||||||
|
- Environment variables (with FASTAPI_TRAFFIC_ prefix)
|
||||||
|
- .env files
|
||||||
|
- JSON files
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
>>> loader = ConfigLoader()
|
||||||
|
>>> global_config = loader.load_global_config_from_env()
|
||||||
|
>>> rate_config = loader.load_rate_limit_config_from_json("config.json")
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("_env_prefix",)
|
||||||
|
|
||||||
|
def __init__(self, env_prefix: str = ENV_PREFIX) -> None:
|
||||||
|
"""Initialize the config loader.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env_prefix: Prefix for environment variables. Defaults to "FASTAPI_TRAFFIC_".
|
||||||
|
"""
|
||||||
|
self._env_prefix = env_prefix
|
||||||
|
|
||||||
|
def _parse_value(self, value: str, target_type: type[Any]) -> Any:
|
||||||
|
"""Parse a string value to the target type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: The string value to parse.
|
||||||
|
target_type: The target type to convert to.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The parsed value.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If the value cannot be parsed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if target_type is bool:
|
||||||
|
return value.lower() in ("true", "1", "yes", "on")
|
||||||
|
if target_type is int:
|
||||||
|
return int(value)
|
||||||
|
if target_type is float:
|
||||||
|
return float(value)
|
||||||
|
if target_type is str:
|
||||||
|
return value
|
||||||
|
if target_type is Algorithm:
|
||||||
|
return Algorithm(value.lower())
|
||||||
|
if target_type is set:
|
||||||
|
# Parse comma-separated values
|
||||||
|
if not value.strip():
|
||||||
|
return set()
|
||||||
|
return {item.strip() for item in value.split(",") if item.strip()}
|
||||||
|
except (ValueError, KeyError) as e:
|
||||||
|
msg = f"Cannot parse value '{value}' as {target_type.__name__}: {e}"
|
||||||
|
raise ConfigurationError(msg) from e
|
||||||
|
|
||||||
|
msg = f"Unsupported type: {target_type}"
|
||||||
|
raise ConfigurationError(msg)
|
||||||
|
|
||||||
|
def _validate_and_convert(
|
||||||
|
self,
|
||||||
|
data: Mapping[str, Any],
|
||||||
|
field_types: dict[str, type[Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate and convert configuration data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Raw configuration data.
|
||||||
|
field_types: Mapping of field names to their expected types.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated and converted configuration dictionary.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If validation fails.
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
|
||||||
|
for key, value in data.items():
|
||||||
|
if key in _NON_LOADABLE_FIELDS:
|
||||||
|
msg = f"Field '{key}' cannot be loaded from configuration files"
|
||||||
|
raise ConfigurationError(msg)
|
||||||
|
|
||||||
|
if key not in field_types:
|
||||||
|
msg = f"Unknown configuration field: '{key}'"
|
||||||
|
raise ConfigurationError(msg)
|
||||||
|
|
||||||
|
target_type = field_types[key]
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
result[key] = self._parse_value(value, target_type)
|
||||||
|
elif target_type is set and isinstance(value, list):
|
||||||
|
result[key] = set(value)
|
||||||
|
elif target_type is Algorithm and isinstance(value, str):
|
||||||
|
result[key] = Algorithm(value.lower())
|
||||||
|
elif isinstance(value, target_type):
|
||||||
|
result[key] = value
|
||||||
|
elif target_type is float and isinstance(value, int):
|
||||||
|
result[key] = float(value)
|
||||||
|
else:
|
||||||
|
msg = f"Invalid type for '{key}': expected {target_type.__name__}, got {type(value).__name__}"
|
||||||
|
raise ConfigurationError(msg)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _load_dotenv_file(self, file_path: Path) -> dict[str, str]:
|
||||||
|
"""Load environment variables from a .env file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the .env file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of environment variable names to values.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If the file cannot be read or parsed.
|
||||||
|
"""
|
||||||
|
if not file_path.exists():
|
||||||
|
msg = f"Configuration file not found: {file_path}"
|
||||||
|
raise ConfigurationError(msg)
|
||||||
|
|
||||||
|
env_vars: dict[str, str] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with file_path.open(encoding="utf-8") as f:
|
||||||
|
for line_num, line in enumerate(f, start=1):
|
||||||
|
line = line.strip()
|
||||||
|
|
||||||
|
# Skip empty lines and comments
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse key=value pairs
|
||||||
|
if "=" not in line:
|
||||||
|
msg = f"Invalid line {line_num} in {file_path}: missing '='"
|
||||||
|
raise ConfigurationError(msg)
|
||||||
|
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
|
||||||
|
# Remove surrounding quotes if present
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
||||||
|
value = value[1:-1]
|
||||||
|
|
||||||
|
env_vars[key] = value
|
||||||
|
|
||||||
|
except OSError as e:
|
||||||
|
msg = f"Failed to read configuration file {file_path}: {e}"
|
||||||
|
raise ConfigurationError(msg) from e
|
||||||
|
|
||||||
|
return env_vars
|
||||||
|
|
||||||
|
def _load_json_file(self, file_path: Path) -> dict[str, Any]:
|
||||||
|
"""Load configuration from a JSON file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the JSON file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configuration dictionary.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If the file cannot be read or parsed.
|
||||||
|
"""
|
||||||
|
if not file_path.exists():
|
||||||
|
msg = f"Configuration file not found: {file_path}"
|
||||||
|
raise ConfigurationError(msg)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with file_path.open(encoding="utf-8") as f:
|
||||||
|
data: dict[str, Any] = json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
msg = f"Invalid JSON in {file_path}: {e}"
|
||||||
|
raise ConfigurationError(msg) from e
|
||||||
|
except OSError as e:
|
||||||
|
msg = f"Failed to read configuration file {file_path}: {e}"
|
||||||
|
raise ConfigurationError(msg) from e
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _extract_env_config(
|
||||||
|
self,
|
||||||
|
prefix: str,
|
||||||
|
field_types: dict[str, type[Any]],
|
||||||
|
env_source: Mapping[str, str] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Extract configuration from environment variables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prefix: The prefix to look for (e.g., "RATE_LIMIT_" or "GLOBAL_").
|
||||||
|
field_types: Mapping of field names to their expected types.
|
||||||
|
env_source: Optional source of environment variables. Defaults to os.environ.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of field names to their string values.
|
||||||
|
"""
|
||||||
|
source = env_source if env_source is not None else os.environ
|
||||||
|
full_prefix = f"{self._env_prefix}{prefix}"
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
|
||||||
|
for key, value in source.items():
|
||||||
|
if key.startswith(full_prefix):
|
||||||
|
field_name = key[len(full_prefix):].lower()
|
||||||
|
if field_name in field_types:
|
||||||
|
result[field_name] = value
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def load_rate_limit_config_from_env(
|
||||||
|
self,
|
||||||
|
env_source: Mapping[str, str] | None = None,
|
||||||
|
**overrides: Any,
|
||||||
|
) -> RateLimitConfig:
|
||||||
|
"""Load RateLimitConfig from environment variables.
|
||||||
|
|
||||||
|
Environment variables should be prefixed with FASTAPI_TRAFFIC_RATE_LIMIT_
|
||||||
|
(e.g., FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env_source: Optional source of environment variables. Defaults to os.environ.
|
||||||
|
**overrides: Additional values to override loaded config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured RateLimitConfig instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If configuration is invalid.
|
||||||
|
"""
|
||||||
|
raw_config = self._extract_env_config(
|
||||||
|
"RATE_LIMIT_", _RATE_LIMIT_FIELD_TYPES, env_source
|
||||||
|
)
|
||||||
|
config_dict = self._validate_and_convert(raw_config, _RATE_LIMIT_FIELD_TYPES)
|
||||||
|
|
||||||
|
# Apply overrides
|
||||||
|
for key, value in overrides.items():
|
||||||
|
if key in _NON_LOADABLE_FIELDS or key in _RATE_LIMIT_FIELD_TYPES:
|
||||||
|
config_dict[key] = value
|
||||||
|
|
||||||
|
# Ensure required field 'limit' is present
|
||||||
|
if "limit" not in config_dict:
|
||||||
|
msg = "Required field 'limit' not found in environment configuration"
|
||||||
|
raise ConfigurationError(msg)
|
||||||
|
|
||||||
|
return RateLimitConfig(**config_dict)
|
||||||
|
|
||||||
|
def load_rate_limit_config_from_dotenv(
|
||||||
|
self,
|
||||||
|
file_path: str | Path,
|
||||||
|
**overrides: Any,
|
||||||
|
) -> RateLimitConfig:
|
||||||
|
"""Load RateLimitConfig from a .env file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the .env file.
|
||||||
|
**overrides: Additional values to override loaded config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured RateLimitConfig instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If configuration is invalid.
|
||||||
|
"""
|
||||||
|
path = Path(file_path)
|
||||||
|
env_vars = self._load_dotenv_file(path)
|
||||||
|
return self.load_rate_limit_config_from_env(env_vars, **overrides)
|
||||||
|
|
||||||
|
def load_rate_limit_config_from_json(
|
||||||
|
self,
|
||||||
|
file_path: str | Path,
|
||||||
|
**overrides: Any,
|
||||||
|
) -> RateLimitConfig:
|
||||||
|
"""Load RateLimitConfig from a JSON file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the JSON file.
|
||||||
|
**overrides: Additional values to override loaded config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured RateLimitConfig instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If configuration is invalid.
|
||||||
|
"""
|
||||||
|
path = Path(file_path)
|
||||||
|
raw_config = self._load_json_file(path)
|
||||||
|
config_dict = self._validate_and_convert(raw_config, _RATE_LIMIT_FIELD_TYPES)
|
||||||
|
|
||||||
|
# Apply overrides
|
||||||
|
for key, value in overrides.items():
|
||||||
|
if key in _NON_LOADABLE_FIELDS or key in _RATE_LIMIT_FIELD_TYPES:
|
||||||
|
config_dict[key] = value
|
||||||
|
|
||||||
|
# Ensure required field 'limit' is present
|
||||||
|
if "limit" not in config_dict:
|
||||||
|
msg = "Required field 'limit' not found in JSON configuration"
|
||||||
|
raise ConfigurationError(msg)
|
||||||
|
|
||||||
|
return RateLimitConfig(**config_dict)
|
||||||
|
|
||||||
|
def load_global_config_from_env(
|
||||||
|
self,
|
||||||
|
env_source: Mapping[str, str] | None = None,
|
||||||
|
**overrides: Any,
|
||||||
|
) -> GlobalConfig:
|
||||||
|
"""Load GlobalConfig from environment variables.
|
||||||
|
|
||||||
|
Environment variables should be prefixed with FASTAPI_TRAFFIC_GLOBAL_
|
||||||
|
(e.g., FASTAPI_TRAFFIC_GLOBAL_ENABLED=true).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env_source: Optional source of environment variables. Defaults to os.environ.
|
||||||
|
**overrides: Additional values to override loaded config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured GlobalConfig instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If configuration is invalid.
|
||||||
|
"""
|
||||||
|
raw_config = self._extract_env_config(
|
||||||
|
"GLOBAL_", _GLOBAL_FIELD_TYPES, env_source
|
||||||
|
)
|
||||||
|
config_dict = self._validate_and_convert(raw_config, _GLOBAL_FIELD_TYPES)
|
||||||
|
|
||||||
|
# Apply overrides
|
||||||
|
for key, value in overrides.items():
|
||||||
|
if key in _NON_LOADABLE_FIELDS or key in _GLOBAL_FIELD_TYPES:
|
||||||
|
config_dict[key] = value
|
||||||
|
|
||||||
|
return GlobalConfig(**config_dict)
|
||||||
|
|
||||||
|
def load_global_config_from_dotenv(
|
||||||
|
self,
|
||||||
|
file_path: str | Path,
|
||||||
|
**overrides: Any,
|
||||||
|
) -> GlobalConfig:
|
||||||
|
"""Load GlobalConfig from a .env file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the .env file.
|
||||||
|
**overrides: Additional values to override loaded config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured GlobalConfig instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If configuration is invalid.
|
||||||
|
"""
|
||||||
|
path = Path(file_path)
|
||||||
|
env_vars = self._load_dotenv_file(path)
|
||||||
|
return self.load_global_config_from_env(env_vars, **overrides)
|
||||||
|
|
||||||
|
def load_global_config_from_json(
|
||||||
|
self,
|
||||||
|
file_path: str | Path,
|
||||||
|
**overrides: Any,
|
||||||
|
) -> GlobalConfig:
|
||||||
|
"""Load GlobalConfig from a JSON file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the JSON file.
|
||||||
|
**overrides: Additional values to override loaded config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured GlobalConfig instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If configuration is invalid.
|
||||||
|
"""
|
||||||
|
path = Path(file_path)
|
||||||
|
raw_config = self._load_json_file(path)
|
||||||
|
config_dict = self._validate_and_convert(raw_config, _GLOBAL_FIELD_TYPES)
|
||||||
|
|
||||||
|
# Apply overrides
|
||||||
|
for key, value in overrides.items():
|
||||||
|
if key in _NON_LOADABLE_FIELDS or key in _GLOBAL_FIELD_TYPES:
|
||||||
|
config_dict[key] = value
|
||||||
|
|
||||||
|
return GlobalConfig(**config_dict)
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience functions for direct usage
|
||||||
|
_default_loader: ConfigLoader | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_default_loader() -> ConfigLoader:
|
||||||
|
"""Get or create the default config loader."""
|
||||||
|
global _default_loader
|
||||||
|
if _default_loader is None:
|
||||||
|
_default_loader = ConfigLoader()
|
||||||
|
return _default_loader
|
||||||
|
|
||||||
|
|
||||||
|
def load_rate_limit_config(
|
||||||
|
source: str | Path,
|
||||||
|
**overrides: Any,
|
||||||
|
) -> RateLimitConfig:
|
||||||
|
"""Load RateLimitConfig from a file (auto-detects format).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: Path to configuration file (.env or .json).
|
||||||
|
**overrides: Additional values to override loaded config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured RateLimitConfig instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If configuration is invalid or format unknown.
|
||||||
|
"""
|
||||||
|
loader = _get_default_loader()
|
||||||
|
path = Path(source)
|
||||||
|
|
||||||
|
if path.suffix.lower() == ".json":
|
||||||
|
return loader.load_rate_limit_config_from_json(path, **overrides)
|
||||||
|
if path.suffix.lower() in (".env", "") or path.name.startswith(".env"):
|
||||||
|
return loader.load_rate_limit_config_from_dotenv(path, **overrides)
|
||||||
|
|
||||||
|
msg = f"Unknown configuration file format: {path.suffix}"
|
||||||
|
raise ConfigurationError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def load_global_config(
|
||||||
|
source: str | Path,
|
||||||
|
**overrides: Any,
|
||||||
|
) -> GlobalConfig:
|
||||||
|
"""Load GlobalConfig from a file (auto-detects format).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: Path to configuration file (.env or .json).
|
||||||
|
**overrides: Additional values to override loaded config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured GlobalConfig instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If configuration is invalid or format unknown.
|
||||||
|
"""
|
||||||
|
loader = _get_default_loader()
|
||||||
|
path = Path(source)
|
||||||
|
|
||||||
|
if path.suffix.lower() == ".json":
|
||||||
|
return loader.load_global_config_from_json(path, **overrides)
|
||||||
|
if path.suffix.lower() in (".env", "") or path.name.startswith(".env"):
|
||||||
|
return loader.load_global_config_from_dotenv(path, **overrides)
|
||||||
|
|
||||||
|
msg = f"Unknown configuration file format: {path.suffix}"
|
||||||
|
raise ConfigurationError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def load_rate_limit_config_from_env(**overrides: Any) -> RateLimitConfig:
|
||||||
|
"""Load RateLimitConfig from environment variables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**overrides: Additional values to override loaded config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured RateLimitConfig instance.
|
||||||
|
"""
|
||||||
|
return _get_default_loader().load_rate_limit_config_from_env(**overrides)
|
||||||
|
|
||||||
|
|
||||||
|
def load_global_config_from_env(**overrides: Any) -> GlobalConfig:
|
||||||
|
"""Load GlobalConfig from environment variables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**overrides: Additional values to override loaded config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured GlobalConfig instance.
|
||||||
|
"""
|
||||||
|
return _get_default_loader().load_global_config_from_env(**overrides)
|
||||||
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
|
|||||||
class RateLimiter:
|
class RateLimiter:
|
||||||
"""Main rate limiter class that manages rate limiting logic."""
|
"""Main rate limiter class that manages rate limiting logic."""
|
||||||
|
|
||||||
__slots__ = ("_config", "_backend", "_algorithms", "_initialized")
|
__slots__ = ("_algorithms", "_backend", "_config", "_initialized")
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -96,15 +96,14 @@ class RateLimiter:
|
|||||||
identifier: str | None = None,
|
identifier: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build the rate limit key for a request."""
|
"""Build the rate limit key for a request."""
|
||||||
if identifier:
|
client_id = identifier or config.key_extractor(request)
|
||||||
client_id = identifier
|
|
||||||
else:
|
|
||||||
client_id = config.key_extractor(request)
|
|
||||||
|
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
method = request.method
|
method = request.method
|
||||||
|
|
||||||
return f"{self._config.key_prefix}:{config.key_prefix}:{method}:{path}:{client_id}"
|
return (
|
||||||
|
f"{self._config.key_prefix}:{config.key_prefix}:{method}:{path}:{client_id}"
|
||||||
|
)
|
||||||
|
|
||||||
def _is_exempt(self, request: Request, config: RateLimitConfig) -> bool:
|
def _is_exempt(self, request: Request, config: RateLimitConfig) -> bool:
|
||||||
"""Check if the request is exempt from rate limiting."""
|
"""Check if the request is exempt from rate limiting."""
|
||||||
@@ -118,10 +117,7 @@ class RateLimiter:
|
|||||||
if client_ip in self._config.exempt_ips:
|
if client_ip in self._config.exempt_ips:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if request.url.path in self._config.exempt_paths:
|
return request.url.path in self._config.exempt_paths
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def check(
|
async def check(
|
||||||
self,
|
self,
|
||||||
|
|||||||
599
tests/test_config_loader.py
Normal file
599
tests/test_config_loader.py
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
"""Tests for configuration loader."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fastapi_traffic.core.algorithms import Algorithm
|
||||||
|
from fastapi_traffic.core.config import GlobalConfig
|
||||||
|
from fastapi_traffic.core.config_loader import (
|
||||||
|
ConfigLoader,
|
||||||
|
load_global_config,
|
||||||
|
load_global_config_from_env,
|
||||||
|
load_rate_limit_config,
|
||||||
|
load_rate_limit_config_from_env,
|
||||||
|
)
|
||||||
|
from fastapi_traffic.exceptions import ConfigurationError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_dir() -> Generator[Path, None, None]:
|
||||||
|
"""Create a temporary directory for test files."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
yield Path(tmpdir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def loader() -> ConfigLoader:
|
||||||
|
"""Create a ConfigLoader instance."""
|
||||||
|
return ConfigLoader()
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigLoaderEnv:
|
||||||
|
"""Tests for loading configuration from environment variables."""
|
||||||
|
|
||||||
|
def test_load_rate_limit_config_from_env(self, loader: ConfigLoader) -> None:
|
||||||
|
"""Test loading RateLimitConfig from environment variables."""
|
||||||
|
env_vars = {
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100",
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE": "60.0",
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM": "token_bucket",
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX": "test",
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_INCLUDE_HEADERS": "true",
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_STATUS_CODE": "429",
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_SKIP_ON_ERROR": "false",
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_COST": "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
config = loader.load_rate_limit_config_from_env(env_vars)
|
||||||
|
|
||||||
|
assert config.limit == 100
|
||||||
|
assert config.window_size == 60.0
|
||||||
|
assert config.algorithm == Algorithm.TOKEN_BUCKET
|
||||||
|
assert config.key_prefix == "test"
|
||||||
|
assert config.include_headers is True
|
||||||
|
assert config.status_code == 429
|
||||||
|
assert config.skip_on_error is False
|
||||||
|
assert config.cost == 1
|
||||||
|
|
||||||
|
def test_load_rate_limit_config_from_env_with_burst_size(
|
||||||
|
self, loader: ConfigLoader
|
||||||
|
) -> None:
|
||||||
|
"""Test loading RateLimitConfig with burst_size."""
|
||||||
|
env_vars = {
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "50",
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_BURST_SIZE": "100",
|
||||||
|
}
|
||||||
|
|
||||||
|
config = loader.load_rate_limit_config_from_env(env_vars)
|
||||||
|
|
||||||
|
assert config.limit == 50
|
||||||
|
assert config.burst_size == 100
|
||||||
|
|
||||||
|
def test_load_rate_limit_config_from_env_missing_limit(
|
||||||
|
self, loader: ConfigLoader
|
||||||
|
) -> None:
|
||||||
|
"""Test that missing 'limit' field raises ConfigurationError."""
|
||||||
|
env_vars = {
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE": "60.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ConfigurationError, match="Required field 'limit'"):
|
||||||
|
loader.load_rate_limit_config_from_env(env_vars)
|
||||||
|
|
||||||
|
def test_load_rate_limit_config_from_env_with_overrides(
|
||||||
|
self, loader: ConfigLoader
|
||||||
|
) -> None:
|
||||||
|
"""Test loading with overrides."""
|
||||||
|
env_vars = {
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100",
|
||||||
|
}
|
||||||
|
|
||||||
|
config = loader.load_rate_limit_config_from_env(
|
||||||
|
env_vars, window_size=120.0, error_message="Custom error"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.limit == 100
|
||||||
|
assert config.window_size == 120.0
|
||||||
|
assert config.error_message == "Custom error"
|
||||||
|
|
||||||
|
def test_load_global_config_from_env(self, loader: ConfigLoader) -> None:
|
||||||
|
"""Test loading GlobalConfig from environment variables."""
|
||||||
|
env_vars = {
|
||||||
|
"FASTAPI_TRAFFIC_GLOBAL_ENABLED": "true",
|
||||||
|
"FASTAPI_TRAFFIC_GLOBAL_DEFAULT_LIMIT": "200",
|
||||||
|
"FASTAPI_TRAFFIC_GLOBAL_DEFAULT_WINDOW_SIZE": "120.0",
|
||||||
|
"FASTAPI_TRAFFIC_GLOBAL_DEFAULT_ALGORITHM": "fixed_window",
|
||||||
|
"FASTAPI_TRAFFIC_GLOBAL_KEY_PREFIX": "global_test",
|
||||||
|
"FASTAPI_TRAFFIC_GLOBAL_INCLUDE_HEADERS": "false",
|
||||||
|
"FASTAPI_TRAFFIC_GLOBAL_STATUS_CODE": "503",
|
||||||
|
"FASTAPI_TRAFFIC_GLOBAL_SKIP_ON_ERROR": "true",
|
||||||
|
"FASTAPI_TRAFFIC_GLOBAL_HEADERS_PREFIX": "X-Custom",
|
||||||
|
}
|
||||||
|
|
||||||
|
config = loader.load_global_config_from_env(env_vars)
|
||||||
|
|
||||||
|
assert config.enabled is True
|
||||||
|
assert config.default_limit == 200
|
||||||
|
assert config.default_window_size == 120.0
|
||||||
|
assert config.default_algorithm == Algorithm.FIXED_WINDOW
|
||||||
|
assert config.key_prefix == "global_test"
|
||||||
|
assert config.include_headers is False
|
||||||
|
assert config.status_code == 503
|
||||||
|
assert config.skip_on_error is True
|
||||||
|
assert config.headers_prefix == "X-Custom"
|
||||||
|
|
||||||
|
def test_load_global_config_from_env_with_sets(self, loader: ConfigLoader) -> None:
|
||||||
|
"""Test loading GlobalConfig with set fields."""
|
||||||
|
env_vars = {
|
||||||
|
"FASTAPI_TRAFFIC_GLOBAL_EXEMPT_IPS": "127.0.0.1, 192.168.1.1, 10.0.0.1",
|
||||||
|
"FASTAPI_TRAFFIC_GLOBAL_EXEMPT_PATHS": "/health, /metrics",
|
||||||
|
}
|
||||||
|
|
||||||
|
config = loader.load_global_config_from_env(env_vars)
|
||||||
|
|
||||||
|
assert config.exempt_ips == {"127.0.0.1", "192.168.1.1", "10.0.0.1"}
|
||||||
|
assert config.exempt_paths == {"/health", "/metrics"}
|
||||||
|
|
||||||
|
def test_load_global_config_from_env_empty_sets(
|
||||||
|
self, loader: ConfigLoader
|
||||||
|
) -> None:
|
||||||
|
"""Test loading GlobalConfig with empty set fields."""
|
||||||
|
env_vars = {
|
||||||
|
"FASTAPI_TRAFFIC_GLOBAL_EXEMPT_IPS": "",
|
||||||
|
"FASTAPI_TRAFFIC_GLOBAL_EXEMPT_PATHS": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
config = loader.load_global_config_from_env(env_vars)
|
||||||
|
|
||||||
|
assert config.exempt_ips == set()
|
||||||
|
assert config.exempt_paths == set()
|
||||||
|
|
||||||
|
def test_load_global_config_defaults(self, loader: ConfigLoader) -> None:
|
||||||
|
"""Test that GlobalConfig uses defaults when no env vars set."""
|
||||||
|
config = loader.load_global_config_from_env({})
|
||||||
|
|
||||||
|
assert config.enabled is True
|
||||||
|
assert config.default_limit == 100
|
||||||
|
assert config.default_window_size == 60.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigLoaderDotenv:
|
||||||
|
"""Tests for loading configuration from .env files."""
|
||||||
|
|
||||||
|
def test_load_rate_limit_config_from_dotenv(
|
||||||
|
self, loader: ConfigLoader, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test loading RateLimitConfig from .env file."""
|
||||||
|
env_file = temp_dir / ".env"
|
||||||
|
env_file.write_text(
|
||||||
|
"""
|
||||||
|
# Rate limit configuration
|
||||||
|
FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=150
|
||||||
|
FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE=30.0
|
||||||
|
FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM=sliding_window
|
||||||
|
FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX=api
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
config = loader.load_rate_limit_config_from_dotenv(env_file)
|
||||||
|
|
||||||
|
assert config.limit == 150
|
||||||
|
assert config.window_size == 30.0
|
||||||
|
assert config.algorithm == Algorithm.SLIDING_WINDOW
|
||||||
|
assert config.key_prefix == "api"
|
||||||
|
|
||||||
|
def test_load_rate_limit_config_from_dotenv_with_quotes(
|
||||||
|
self, loader: ConfigLoader, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test loading config with quoted values."""
|
||||||
|
env_file = temp_dir / ".env"
|
||||||
|
env_file.write_text(
|
||||||
|
"""
|
||||||
|
FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100
|
||||||
|
FASTAPI_TRAFFIC_RATE_LIMIT_ERROR_MESSAGE="Custom error message"
|
||||||
|
FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX='quoted_prefix'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
config = loader.load_rate_limit_config_from_dotenv(env_file)
|
||||||
|
|
||||||
|
assert config.limit == 100
|
||||||
|
assert config.error_message == "Custom error message"
|
||||||
|
assert config.key_prefix == "quoted_prefix"
|
||||||
|
|
||||||
|
def test_load_global_config_from_dotenv(
|
||||||
|
self, loader: ConfigLoader, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test loading GlobalConfig from .env file."""
|
||||||
|
env_file = temp_dir / ".env"
|
||||||
|
env_file.write_text(
|
||||||
|
"""
|
||||||
|
FASTAPI_TRAFFIC_GLOBAL_ENABLED=false
|
||||||
|
FASTAPI_TRAFFIC_GLOBAL_DEFAULT_LIMIT=500
|
||||||
|
FASTAPI_TRAFFIC_GLOBAL_EXEMPT_IPS=10.0.0.1,10.0.0.2
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
config = loader.load_global_config_from_dotenv(env_file)
|
||||||
|
|
||||||
|
assert config.enabled is False
|
||||||
|
assert config.default_limit == 500
|
||||||
|
assert config.exempt_ips == {"10.0.0.1", "10.0.0.2"}
|
||||||
|
|
||||||
|
def test_load_from_dotenv_file_not_found(
|
||||||
|
self, loader: ConfigLoader, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test that missing file raises ConfigurationError."""
|
||||||
|
with pytest.raises(ConfigurationError, match="not found"):
|
||||||
|
loader.load_rate_limit_config_from_dotenv(temp_dir / "nonexistent.env")
|
||||||
|
|
||||||
|
def test_load_from_dotenv_invalid_line(
|
||||||
|
self, loader: ConfigLoader, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test that invalid line format raises ConfigurationError."""
|
||||||
|
env_file = temp_dir / ".env"
|
||||||
|
env_file.write_text(
|
||||||
|
"""
|
||||||
|
FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100
|
||||||
|
invalid line without equals
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ConfigurationError, match="missing '='"):
|
||||||
|
loader.load_rate_limit_config_from_dotenv(env_file)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigLoaderJson:
|
||||||
|
"""Tests for loading configuration from JSON files."""
|
||||||
|
|
||||||
|
def test_load_rate_limit_config_from_json(
|
||||||
|
self, loader: ConfigLoader, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test loading RateLimitConfig from JSON file."""
|
||||||
|
json_file = temp_dir / "config.json"
|
||||||
|
config_data = {
|
||||||
|
"limit": 200,
|
||||||
|
"window_size": 45.0,
|
||||||
|
"algorithm": "leaky_bucket",
|
||||||
|
"key_prefix": "json_test",
|
||||||
|
"include_headers": False,
|
||||||
|
"status_code": 429,
|
||||||
|
"cost": 2,
|
||||||
|
}
|
||||||
|
json_file.write_text(json.dumps(config_data))
|
||||||
|
|
||||||
|
config = loader.load_rate_limit_config_from_json(json_file)
|
||||||
|
|
||||||
|
assert config.limit == 200
|
||||||
|
assert config.window_size == 45.0
|
||||||
|
assert config.algorithm == Algorithm.LEAKY_BUCKET
|
||||||
|
assert config.key_prefix == "json_test"
|
||||||
|
assert config.include_headers is False
|
||||||
|
assert config.status_code == 429
|
||||||
|
assert config.cost == 2
|
||||||
|
|
||||||
|
def test_load_rate_limit_config_from_json_with_int_window(
|
||||||
|
self, loader: ConfigLoader, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test that integer window_size is converted to float."""
|
||||||
|
json_file = temp_dir / "config.json"
|
||||||
|
config_data = {"limit": 100, "window_size": 60}
|
||||||
|
json_file.write_text(json.dumps(config_data))
|
||||||
|
|
||||||
|
config = loader.load_rate_limit_config_from_json(json_file)
|
||||||
|
|
||||||
|
assert config.window_size == 60.0
|
||||||
|
assert isinstance(config.window_size, float)
|
||||||
|
|
||||||
|
def test_load_global_config_from_json(
|
||||||
|
self, loader: ConfigLoader, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test loading GlobalConfig from JSON file."""
|
||||||
|
json_file = temp_dir / "config.json"
|
||||||
|
config_data = {
|
||||||
|
"enabled": True,
|
||||||
|
"default_limit": 1000,
|
||||||
|
"default_window_size": 300.0,
|
||||||
|
"default_algorithm": "sliding_window_counter",
|
||||||
|
"exempt_ips": ["127.0.0.1", "::1"],
|
||||||
|
"exempt_paths": ["/health", "/ready", "/metrics"],
|
||||||
|
}
|
||||||
|
json_file.write_text(json.dumps(config_data))
|
||||||
|
|
||||||
|
config = loader.load_global_config_from_json(json_file)
|
||||||
|
|
||||||
|
assert config.enabled is True
|
||||||
|
assert config.default_limit == 1000
|
||||||
|
assert config.default_window_size == 300.0
|
||||||
|
assert config.default_algorithm == Algorithm.SLIDING_WINDOW_COUNTER
|
||||||
|
assert config.exempt_ips == {"127.0.0.1", "::1"}
|
||||||
|
assert config.exempt_paths == {"/health", "/ready", "/metrics"}
|
||||||
|
|
||||||
|
def test_load_from_json_file_not_found(
|
||||||
|
self, loader: ConfigLoader, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test that missing file raises ConfigurationError."""
|
||||||
|
with pytest.raises(ConfigurationError, match="not found"):
|
||||||
|
loader.load_rate_limit_config_from_json(temp_dir / "nonexistent.json")
|
||||||
|
|
||||||
|
def test_load_from_json_invalid_json(
|
||||||
|
self, loader: ConfigLoader, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test that invalid JSON raises ConfigurationError."""
|
||||||
|
json_file = temp_dir / "config.json"
|
||||||
|
json_file.write_text("{ invalid json }")
|
||||||
|
|
||||||
|
with pytest.raises(ConfigurationError, match="Invalid JSON"):
|
||||||
|
loader.load_rate_limit_config_from_json(json_file)
|
||||||
|
|
||||||
|
def test_load_from_json_non_object_root(
|
||||||
|
self, loader: ConfigLoader, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test that non-object JSON root raises ConfigurationError."""
|
||||||
|
json_file = temp_dir / "config.json"
|
||||||
|
json_file.write_text("[1, 2, 3]")
|
||||||
|
|
||||||
|
with pytest.raises(ConfigurationError, match="JSON root must be an object"):
|
||||||
|
loader.load_rate_limit_config_from_json(json_file)
|
||||||
|
|
||||||
|
def test_load_from_json_missing_limit(
|
||||||
|
self, loader: ConfigLoader, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test that missing 'limit' field raises ConfigurationError."""
|
||||||
|
json_file = temp_dir / "config.json"
|
||||||
|
config_data = {"window_size": 60.0}
|
||||||
|
json_file.write_text(json.dumps(config_data))
|
||||||
|
|
||||||
|
with pytest.raises(ConfigurationError, match="Required field 'limit'"):
|
||||||
|
loader.load_rate_limit_config_from_json(json_file)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigLoaderValidation:
|
||||||
|
"""Tests for configuration validation."""
|
||||||
|
|
||||||
|
def test_invalid_algorithm_value(self, loader: ConfigLoader) -> None:
|
||||||
|
"""Test that invalid algorithm raises ConfigurationError."""
|
||||||
|
env_vars = {
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100",
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM": "invalid_algorithm",
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ConfigurationError, match="Cannot parse value"):
|
||||||
|
loader.load_rate_limit_config_from_env(env_vars)
|
||||||
|
|
||||||
|
def test_invalid_int_value(self, loader: ConfigLoader) -> None:
|
||||||
|
"""Test that invalid integer raises ConfigurationError."""
|
||||||
|
env_vars = {
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "not_a_number",
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ConfigurationError, match="Cannot parse value"):
|
||||||
|
loader.load_rate_limit_config_from_env(env_vars)
|
||||||
|
|
||||||
|
def test_invalid_float_value(self, loader: ConfigLoader) -> None:
|
||||||
|
"""Test that invalid float raises ConfigurationError."""
|
||||||
|
env_vars = {
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100",
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE": "not_a_float",
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ConfigurationError, match="Cannot parse value"):
|
||||||
|
loader.load_rate_limit_config_from_env(env_vars)
|
||||||
|
|
||||||
|
def test_unknown_field(self, loader: ConfigLoader, temp_dir: Path) -> None:
|
||||||
|
"""Test that unknown field raises ConfigurationError."""
|
||||||
|
json_file = temp_dir / "config.json"
|
||||||
|
config_data = {"limit": 100, "unknown_field": "value"}
|
||||||
|
json_file.write_text(json.dumps(config_data))
|
||||||
|
|
||||||
|
with pytest.raises(ConfigurationError, match="Unknown configuration field"):
|
||||||
|
loader.load_rate_limit_config_from_json(json_file)
|
||||||
|
|
||||||
|
def test_non_loadable_field(self, loader: ConfigLoader, temp_dir: Path) -> None:
|
||||||
|
"""Test that non-loadable field raises ConfigurationError."""
|
||||||
|
json_file = temp_dir / "config.json"
|
||||||
|
config_data = {"limit": 100, "key_extractor": "some_function"}
|
||||||
|
json_file.write_text(json.dumps(config_data))
|
||||||
|
|
||||||
|
with pytest.raises(ConfigurationError, match="cannot be loaded"):
|
||||||
|
loader.load_rate_limit_config_from_json(json_file)
|
||||||
|
|
||||||
|
def test_invalid_type_in_json(self, loader: ConfigLoader, temp_dir: Path) -> None:
|
||||||
|
"""Test that invalid type in JSON raises ConfigurationError."""
|
||||||
|
json_file = temp_dir / "config.json"
|
||||||
|
config_data = {"limit": "not_an_int"}
|
||||||
|
json_file.write_text(json.dumps(config_data))
|
||||||
|
|
||||||
|
with pytest.raises(ConfigurationError, match="Cannot parse value"):
|
||||||
|
loader.load_rate_limit_config_from_json(json_file)
|
||||||
|
|
||||||
|
def test_bool_parsing_variations(self, loader: ConfigLoader) -> None:
|
||||||
|
"""Test various boolean string representations."""
|
||||||
|
for true_val in ["true", "True", "TRUE", "1", "yes", "Yes", "on", "ON"]:
|
||||||
|
env_vars = {
|
||||||
|
"FASTAPI_TRAFFIC_GLOBAL_ENABLED": true_val,
|
||||||
|
}
|
||||||
|
config = loader.load_global_config_from_env(env_vars)
|
||||||
|
assert config.enabled is True, f"Failed for value: {true_val}"
|
||||||
|
|
||||||
|
for false_val in ["false", "False", "FALSE", "0", "no", "No", "off", "OFF"]:
|
||||||
|
env_vars = {
|
||||||
|
"FASTAPI_TRAFFIC_GLOBAL_ENABLED": false_val,
|
||||||
|
}
|
||||||
|
config = loader.load_global_config_from_env(env_vars)
|
||||||
|
assert config.enabled is False, f"Failed for value: {false_val}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvenienceFunctions:
|
||||||
|
"""Tests for convenience functions."""
|
||||||
|
|
||||||
|
def test_load_rate_limit_config_json(self, temp_dir: Path) -> None:
|
||||||
|
"""Test load_rate_limit_config with JSON file."""
|
||||||
|
json_file = temp_dir / "config.json"
|
||||||
|
config_data = {"limit": 100}
|
||||||
|
json_file.write_text(json.dumps(config_data))
|
||||||
|
|
||||||
|
config = load_rate_limit_config(json_file)
|
||||||
|
|
||||||
|
assert config.limit == 100
|
||||||
|
|
||||||
|
def test_load_rate_limit_config_dotenv(self, temp_dir: Path) -> None:
|
||||||
|
"""Test load_rate_limit_config with .env file."""
|
||||||
|
env_file = temp_dir / ".env"
|
||||||
|
env_file.write_text("FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=200")
|
||||||
|
|
||||||
|
config = load_rate_limit_config(env_file)
|
||||||
|
|
||||||
|
assert config.limit == 200
|
||||||
|
|
||||||
|
def test_load_rate_limit_config_env_suffix(self, temp_dir: Path) -> None:
|
||||||
|
"""Test load_rate_limit_config with .env suffix."""
|
||||||
|
env_file = temp_dir / "custom.env"
|
||||||
|
env_file.write_text("FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=300")
|
||||||
|
|
||||||
|
config = load_rate_limit_config(env_file)
|
||||||
|
|
||||||
|
assert config.limit == 300
|
||||||
|
|
||||||
|
def test_load_global_config_json(self, temp_dir: Path) -> None:
|
||||||
|
"""Test load_global_config with JSON file."""
|
||||||
|
json_file = temp_dir / "config.json"
|
||||||
|
config_data = {"default_limit": 500}
|
||||||
|
json_file.write_text(json.dumps(config_data))
|
||||||
|
|
||||||
|
config = load_global_config(json_file)
|
||||||
|
|
||||||
|
assert config.default_limit == 500
|
||||||
|
|
||||||
|
def test_load_global_config_dotenv(self, temp_dir: Path) -> None:
|
||||||
|
"""Test load_global_config with .env file."""
|
||||||
|
env_file = temp_dir / ".env"
|
||||||
|
env_file.write_text("FASTAPI_TRAFFIC_GLOBAL_DEFAULT_LIMIT=600")
|
||||||
|
|
||||||
|
config = load_global_config(env_file)
|
||||||
|
|
||||||
|
assert config.default_limit == 600
|
||||||
|
|
||||||
|
def test_load_config_unknown_format(self, temp_dir: Path) -> None:
|
||||||
|
"""Test that unknown file format raises ConfigurationError."""
|
||||||
|
unknown_file = temp_dir / "config.yaml"
|
||||||
|
unknown_file.write_text("limit: 100")
|
||||||
|
|
||||||
|
with pytest.raises(ConfigurationError, match="Unknown configuration file"):
|
||||||
|
load_rate_limit_config(unknown_file)
|
||||||
|
|
||||||
|
def test_load_rate_limit_config_from_env_function(self) -> None:
|
||||||
|
"""Test load_rate_limit_config_from_env convenience function."""
|
||||||
|
# This will use defaults since no env vars are set
|
||||||
|
# We need to provide the limit as an override
|
||||||
|
config = load_rate_limit_config_from_env(limit=100)
|
||||||
|
|
||||||
|
assert config.limit == 100
|
||||||
|
|
||||||
|
def test_load_global_config_from_env_function(self) -> None:
|
||||||
|
"""Test load_global_config_from_env convenience function."""
|
||||||
|
config = load_global_config_from_env()
|
||||||
|
|
||||||
|
assert isinstance(config, GlobalConfig)
|
||||||
|
assert config.enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomEnvPrefix:
|
||||||
|
"""Tests for custom environment variable prefix."""
|
||||||
|
|
||||||
|
def test_custom_prefix(self) -> None:
|
||||||
|
"""Test loading with custom environment prefix."""
|
||||||
|
loader = ConfigLoader(env_prefix="CUSTOM_")
|
||||||
|
env_vars = {
|
||||||
|
"CUSTOM_RATE_LIMIT_LIMIT": "100",
|
||||||
|
"CUSTOM_RATE_LIMIT_WINDOW_SIZE": "30.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
config = loader.load_rate_limit_config_from_env(env_vars)
|
||||||
|
|
||||||
|
assert config.limit == 100
|
||||||
|
assert config.window_size == 30.0
|
||||||
|
|
||||||
|
def test_custom_prefix_global(self) -> None:
|
||||||
|
"""Test loading GlobalConfig with custom prefix."""
|
||||||
|
loader = ConfigLoader(env_prefix="MY_APP_")
|
||||||
|
env_vars = {
|
||||||
|
"MY_APP_GLOBAL_ENABLED": "false",
|
||||||
|
"MY_APP_GLOBAL_DEFAULT_LIMIT": "250",
|
||||||
|
}
|
||||||
|
|
||||||
|
config = loader.load_global_config_from_env(env_vars)
|
||||||
|
|
||||||
|
assert config.enabled is False
|
||||||
|
assert config.default_limit == 250
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllAlgorithms:
|
||||||
|
"""Tests for all algorithm types."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"algorithm_str,expected",
|
||||||
|
[
|
||||||
|
("token_bucket", Algorithm.TOKEN_BUCKET),
|
||||||
|
("sliding_window", Algorithm.SLIDING_WINDOW),
|
||||||
|
("fixed_window", Algorithm.FIXED_WINDOW),
|
||||||
|
("leaky_bucket", Algorithm.LEAKY_BUCKET),
|
||||||
|
("sliding_window_counter", Algorithm.SLIDING_WINDOW_COUNTER),
|
||||||
|
("TOKEN_BUCKET", Algorithm.TOKEN_BUCKET),
|
||||||
|
("SLIDING_WINDOW", Algorithm.SLIDING_WINDOW),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_algorithm_parsing(
|
||||||
|
self, loader: ConfigLoader, algorithm_str: str, expected: Algorithm
|
||||||
|
) -> None:
|
||||||
|
"""Test that all algorithm values are parsed correctly."""
|
||||||
|
env_vars = {
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100",
|
||||||
|
"FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM": algorithm_str,
|
||||||
|
}
|
||||||
|
|
||||||
|
config = loader.load_rate_limit_config_from_env(env_vars)
|
||||||
|
|
||||||
|
assert config.algorithm == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestDataclassValidation:
|
||||||
|
"""Tests that dataclass validation still works after loading."""
|
||||||
|
|
||||||
|
def test_invalid_limit_value(self, loader: ConfigLoader, temp_dir: Path) -> None:
|
||||||
|
"""Test that invalid limit value is caught by dataclass validation."""
|
||||||
|
json_file = temp_dir / "config.json"
|
||||||
|
config_data = {"limit": 0}
|
||||||
|
json_file.write_text(json.dumps(config_data))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="limit must be positive"):
|
||||||
|
loader.load_rate_limit_config_from_json(json_file)
|
||||||
|
|
||||||
|
def test_invalid_window_size_value(
|
||||||
|
self, loader: ConfigLoader, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test that invalid window_size is caught by dataclass validation."""
|
||||||
|
json_file = temp_dir / "config.json"
|
||||||
|
config_data = {"limit": 100, "window_size": -1.0}
|
||||||
|
json_file.write_text(json.dumps(config_data))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="window_size must be positive"):
|
||||||
|
loader.load_rate_limit_config_from_json(json_file)
|
||||||
|
|
||||||
|
def test_invalid_cost_value(self, loader: ConfigLoader, temp_dir: Path) -> None:
|
||||||
|
"""Test that invalid cost is caught by dataclass validation."""
|
||||||
|
json_file = temp_dir / "config.json"
|
||||||
|
config_data = {"limit": 100, "cost": 0}
|
||||||
|
json_file.write_text(json.dumps(config_data))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="cost must be positive"):
|
||||||
|
loader.load_rate_limit_config_from_json(json_file)
|
||||||
Reference in New Issue
Block a user