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:
2026-02-01 13:59:32 +00:00
parent 6c5584c6b4
commit fb23e3c7cf
8 changed files with 1665 additions and 24 deletions

View File

@@ -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

View 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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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",
] ]

View 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)

View File

@@ -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
View 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)