├── premier ├── py.typed ├── features │ ├── __init__.py │ ├── throttler │ │ ├── __init__.py │ │ ├── api.py │ │ ├── errors.py │ │ ├── throttler.py │ │ ├── interface.py │ │ └── handler.py │ ├── timer_errors.py │ ├── cache_errors.py │ ├── retry_errors.py │ ├── cache.py │ ├── timer.py │ └── retry.py ├── errors.py ├── dashboard │ ├── __init__.py │ └── service.py ├── providers │ ├── __init__.py │ ├── interace.py │ ├── redis.py │ └── memory.py ├── __init__.py ├── interface.py ├── _logs.py ├── asgi │ ├── __init__.py │ ├── loadbalancer.py │ └── forward.py └── main.py ├── tests ├── __init__.py ├── test_aiothrottling.py ├── conftest.py ├── test_retry_simple.py ├── test_timeout_simple.py ├── test_loadbalancer.py ├── test_throttling.py ├── test_facade.py ├── test_circuit_breaker.py └── test_gateway_servers_config.py ├── .python-version ├── .gitattributes ├── docs ├── images │ ├── dashboard.png │ ├── fastapi_routes.png │ └── dashboard_config.png ├── installation.md ├── README.md ├── web-gui.md ├── web-gui.md.bak ├── examples.md ├── examples.md.bak ├── quickstart.md ├── index.md └── configuration.md ├── .github └── workflows │ └── docs.yml ├── LICENSE ├── example ├── main.py ├── gateway.yaml ├── README.md └── fastapi │ └── app.py ├── makefile ├── pyproject.toml ├── mkdocs.yml ├── .gitignore ├── example_asgi.py ├── example_facade.py ├── example_logging.py └── CHANGELOG.md /premier/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.10 2 | -------------------------------------------------------------------------------- /premier/features/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /premier/errors.py: -------------------------------------------------------------------------------- 1 | class PremierError(Exception): ... 2 | 3 | 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # GitHub syntax highlighting 2 | pixi.lock linguist-language=YAML 3 | 4 | -------------------------------------------------------------------------------- /docs/images/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raceychan/premier/HEAD/docs/images/dashboard.png -------------------------------------------------------------------------------- /docs/images/fastapi_routes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raceychan/premier/HEAD/docs/images/fastapi_routes.png -------------------------------------------------------------------------------- /docs/images/dashboard_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raceychan/premier/HEAD/docs/images/dashboard_config.png -------------------------------------------------------------------------------- /premier/dashboard/__init__.py: -------------------------------------------------------------------------------- 1 | from .dashboard import DashboardHandler 2 | from .service import DashboardService 3 | 4 | __all__ = ["DashboardHandler", "DashboardService"] -------------------------------------------------------------------------------- /premier/providers/__init__.py: -------------------------------------------------------------------------------- 1 | from premier.providers.interace import AsyncCacheProvider 2 | from premier.providers.memory import AsyncInMemoryCache 3 | 4 | __all__ = [ 5 | "AsyncCacheProvider", 6 | "AsyncInMemoryCache", 7 | ] 8 | -------------------------------------------------------------------------------- /premier/__init__.py: -------------------------------------------------------------------------------- 1 | from .asgi.gateway import ASGIGateway as ASGIGateway 2 | from .main import Premier as Premier 3 | from .features.throttler.errors import QuotaExceedsError as QuotaExceedsError 4 | from .features.throttler.throttler import Throttler as Throttler 5 | from .features.timer import ILogger as ILogger 6 | 7 | VERSION = "0.4.10" 8 | __version__ = VERSION 9 | -------------------------------------------------------------------------------- /premier/interface.py: -------------------------------------------------------------------------------- 1 | from typing import Awaitable, Callable, ParamSpec, TypeVar 2 | 3 | P = ParamSpec("P") 4 | R = TypeVar("R") 5 | 6 | SimpleDecor = Callable[P, R] 7 | ParamDecor = Callable[[Callable[P, R]], Callable[P, R]] 8 | AsyncSimpleDecor = Callable[P, Awaitable[R]] 9 | AsyncParamDecor = Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]] 10 | FlexDecor = ( 11 | SimpleDecor[P, R] 12 | | ParamDecor[P, R] 13 | | AsyncSimpleDecor[P, R] 14 | | AsyncParamDecor[P, R] 15 | ) 16 | -------------------------------------------------------------------------------- /premier/features/throttler/__init__.py: -------------------------------------------------------------------------------- 1 | from premier.features.throttler.api import ( 2 | fixed_window, 3 | leaky_bucket, 4 | sliding_window, 5 | token_bucket, 6 | ) 7 | from premier.features.throttler.interface import KeyMaker, ThrottleAlgo 8 | from premier.features.throttler.throttler import AsyncDefaultHandler, Throttler 9 | from premier.features.throttler.errors import QuotaExceedsError 10 | 11 | __all__ = [ 12 | "fixed_window", 13 | "sliding_window", 14 | "token_bucket", 15 | "leaky_bucket", 16 | "Throttler", 17 | "QuotaExceedsError", 18 | "AsyncDefaultHandler", 19 | "ThrottleAlgo", 20 | "KeyMaker", 21 | ] 22 | -------------------------------------------------------------------------------- /premier/_logs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | # Create a logger 4 | logger = logging.getLogger("prmeier") 5 | logger.setLevel(logging.DEBUG) # Set the minimum level of logs to capture 6 | 7 | # Create handlers for both file and console 8 | console_handler = logging.StreamHandler() 9 | 10 | # Set logging levels for each handler 11 | console_handler.setLevel(logging.DEBUG) # Debug and above go to the console 12 | 13 | # Create formatters and add them to the handlers 14 | console_format = logging.Formatter( 15 | "%(name)s | %(levelname)s | %(asctime)s | %(message)s" 16 | ) 17 | 18 | console_handler.setFormatter(console_format) 19 | 20 | # Add handlers to the logger 21 | logger.addHandler(console_handler) 22 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v5 16 | with: 17 | python-version: "3.12" 18 | - uses: actions/cache@v4 19 | with: 20 | key: mkdocs-material-${{ github.ref }} 21 | path: .cache 22 | - run: pip install mkdocs-material[imaging] 23 | - run: pip install mkdocs-git-revision-date-localized-plugin mkdocs-minify-plugin 24 | - run: pip install pillow cairosvg 25 | - run: mkdocs gh-deploy --force -------------------------------------------------------------------------------- /premier/features/timer_errors.py: -------------------------------------------------------------------------------- 1 | from premier.errors import PremierError 2 | 3 | 4 | class TimerError(PremierError): 5 | """Base class for timer-related errors.""" 6 | pass 7 | 8 | 9 | class TimeoutError(TimerError): 10 | """Raised when a function call times out.""" 11 | 12 | def __init__(self, timeout_seconds: float, function_name: str = ""): 13 | self.timeout_seconds = timeout_seconds 14 | self.function_name = function_name 15 | message = f"Function {function_name} timed out after {timeout_seconds}s" if function_name else f"Operation timed out after {timeout_seconds}s" 16 | super().__init__(message) 17 | 18 | 19 | class TimerConfigurationError(TimerError): 20 | """Raised when timer configuration is invalid.""" 21 | pass 22 | 23 | 24 | class LoggerError(TimerError): 25 | """Raised when there's an issue with the logger.""" 26 | pass -------------------------------------------------------------------------------- /premier/providers/interace.py: -------------------------------------------------------------------------------- 1 | import typing as ty 2 | from typing import Protocol, runtime_checkable 3 | 4 | 5 | @runtime_checkable 6 | class AsyncCacheProvider(Protocol): 7 | async def get(self, key: str) -> ty.Any: 8 | """Get value by key. Returns None if key doesn't exist.""" 9 | ... 10 | 11 | async def set(self, key: str, value: ty.Any, ex: int | None = None) -> None: 12 | """Set key-value pair with optional expiration time in seconds.""" 13 | ... 14 | 15 | async def delete(self, key: str) -> None: 16 | """Delete key.""" 17 | ... 18 | 19 | async def clear(self, keyspace: str = "") -> None: 20 | """Clear all keys with the given prefix. If empty, clear all.""" 21 | ... 22 | 23 | async def exists(self, key: str) -> bool: 24 | """Check if key exists.""" 25 | ... 26 | 27 | async def close(self) -> None: 28 | """Close the cache provider.""" 29 | ... 30 | -------------------------------------------------------------------------------- /premier/features/cache_errors.py: -------------------------------------------------------------------------------- 1 | from premier.errors import PremierError 2 | 3 | 4 | class CacheError(PremierError): 5 | """Base class for cache-related errors.""" 6 | pass 7 | 8 | 9 | class CacheKeyError(CacheError): 10 | """Raised when there's an issue with cache key generation.""" 11 | 12 | def __init__(self, key: str, message: str = ""): 13 | self.key = key 14 | super().__init__(message or f"Invalid cache key: {key}") 15 | 16 | 17 | class CacheProviderError(CacheError): 18 | """Raised when there's an issue with the cache provider.""" 19 | pass 20 | 21 | 22 | class CacheSerializationError(CacheError): 23 | """Raised when encoding/decoding cache values fails.""" 24 | 25 | def __init__(self, value, message: str = ""): 26 | self.value = value 27 | super().__init__(message or f"Failed to serialize cache value: {value}") 28 | 29 | 30 | class CacheConnectionError(CacheError): 31 | """Raised when cache provider connection fails.""" 32 | pass -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 racey chan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /premier/features/retry_errors.py: -------------------------------------------------------------------------------- 1 | from premier.errors import PremierError 2 | 3 | 4 | class RetryError(PremierError): 5 | """Base class for retry-related errors.""" 6 | pass 7 | 8 | 9 | class MaxRetriesExceededError(RetryError): 10 | """Raised when maximum retry attempts are exceeded.""" 11 | 12 | def __init__(self, max_attempts: int, last_exception: Exception): 13 | self.max_attempts = max_attempts 14 | self.last_exception = last_exception 15 | super().__init__(f"Max retries ({max_attempts}) exceeded. Last error: {last_exception}") 16 | 17 | 18 | class RetryConfigurationError(RetryError): 19 | """Raised when retry configuration is invalid.""" 20 | pass 21 | 22 | 23 | class CircuitBreakerError(PremierError): 24 | """Base class for circuit breaker errors.""" 25 | pass 26 | 27 | 28 | class CircuitBreakerOpenError(CircuitBreakerError): 29 | """Raised when circuit breaker is open and blocking requests.""" 30 | 31 | def __init__(self, message: str = "Circuit breaker is open"): 32 | super().__init__(message) 33 | 34 | 35 | class CircuitBreakerConfigurationError(CircuitBreakerError): 36 | """Raised when circuit breaker configuration is invalid.""" 37 | pass -------------------------------------------------------------------------------- /tests/test_aiothrottling.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import pytest as pytest 5 | 6 | from premier import QuotaExceedsError, Throttler 7 | 8 | 9 | async def test_async_throttler_with_leaky_bucket( 10 | aiothrottler: Throttler, logger: logging.Logger 11 | ): 12 | bucket_size = 3 13 | quota = 1 14 | duration = 1 15 | 16 | @aiothrottler.leaky_bucket( 17 | quota=quota, 18 | duration=duration, 19 | bucket_size=bucket_size, 20 | ) 21 | async def add(a: int, b: int) -> None: 22 | # Remove sleep to speed up test 23 | pass 24 | 25 | todo = set[asyncio.Task[None]]() 26 | rejected = 0 27 | 28 | tries = 6 29 | for _ in range(tries): 30 | task = asyncio.create_task(add(3, 5)) 31 | todo.add(task) 32 | done, _ = await asyncio.wait(todo) 33 | 34 | for e in done: 35 | try: 36 | e.result() 37 | except QuotaExceedsError: 38 | rejected += 1 39 | 40 | # In leaky bucket: bucket_size allows immediate tasks, quota determines rate 41 | # So we expect bucket_size tasks to succeed immediately, rest rejected 42 | expected_rejected = tries - bucket_size 43 | assert rejected == expected_rejected 44 | -------------------------------------------------------------------------------- /premier/asgi/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Premier Gateway Module 3 | 4 | This module provides ASGI gateway functionality with configurable features 5 | including caching, rate limiting, retry logic, timeouts, and monitoring. 6 | """ 7 | 8 | from .gateway import ( 9 | ASGIGateway, 10 | CacheConfig, 11 | CircuitBreakerConfig, 12 | FeatureConfig, 13 | GatewayConfig, 14 | MonitoringConfig, 15 | PathConfig, 16 | RateLimitConfig, 17 | RetryConfig, 18 | TimeoutConfig, 19 | create_gateway, 20 | ) 21 | from .loadbalancer import ILoadBalancer, RandomLoadBalancer, RoundRobinLoadBalancer, create_random_load_balancer, create_round_robin_load_balancer 22 | 23 | __all__ = [ 24 | # Main classes 25 | "ASGIGateway", 26 | "GatewayConfig", 27 | # Configuration classes 28 | "CacheConfig", 29 | "CircuitBreakerConfig", 30 | "FeatureConfig", 31 | "MonitoringConfig", 32 | "PathConfig", 33 | "RateLimitConfig", 34 | "RetryConfig", 35 | "TimeoutConfig", 36 | # Load balancer 37 | "ILoadBalancer", 38 | "RandomLoadBalancer", 39 | "RoundRobinLoadBalancer", 40 | # Factory functions 41 | "create_gateway", 42 | "create_random_load_balancer", 43 | "create_round_robin_load_balancer", 44 | ] 45 | -------------------------------------------------------------------------------- /premier/features/throttler/api.py: -------------------------------------------------------------------------------- 1 | from .interface import KeyMaker, ThrottleAlgo 2 | from .throttler import Throttler 3 | 4 | _DEFAULT_THROTTLER = Throttler() 5 | 6 | 7 | def fixed_window(quota: int, duration_s: int, keymaker: KeyMaker | None = None): 8 | return _DEFAULT_THROTTLER.throttle( 9 | quota=quota, 10 | duration=duration_s, 11 | throttle_algo=ThrottleAlgo.FIXED_WINDOW, 12 | keymaker=keymaker, 13 | ) 14 | 15 | 16 | def sliding_window(quota: int, duration_s: int, keymaker: KeyMaker | None = None): 17 | return _DEFAULT_THROTTLER.throttle( 18 | quota=quota, 19 | duration=duration_s, 20 | throttle_algo=ThrottleAlgo.SLIDING_WINDOW, 21 | keymaker=keymaker, 22 | ) 23 | 24 | 25 | def token_bucket(quota: int, duration_s: int, keymaker: KeyMaker | None = None): 26 | return _DEFAULT_THROTTLER.throttle( 27 | quota=quota, 28 | duration=duration_s, 29 | throttle_algo=ThrottleAlgo.TOKEN_BUCKET, 30 | keymaker=keymaker, 31 | ) 32 | 33 | 34 | def leaky_bucket( 35 | bucket_size: int, quota: int, duration_s: int, keymaker: KeyMaker | None = None 36 | ): 37 | return _DEFAULT_THROTTLER.throttle( 38 | bucket_size=bucket_size, 39 | quota=quota, 40 | throttle_algo=ThrottleAlgo.LEAKY_BUCKET, 41 | duration=duration_s, 42 | keymaker=keymaker, 43 | ) 44 | -------------------------------------------------------------------------------- /example/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example application demonstrating Premier API Gateway with dashboard. 3 | 4 | Run with: 5 | uvicorn main:gateway --host 0.0.0.0 --port 8000 --reload 6 | 7 | Then visit: 8 | - http://localhost:8000/premier/dashboard - Premier Dashboard 9 | - http://localhost:8000/health - Health check 10 | - http://localhost:8000/api/users - Users API (cached) 11 | - http://localhost:8000/api/products - Products API (cached) 12 | - http://localhost:8000/api/admin/stats - Admin endpoint (rate limited) 13 | - http://localhost:8000/api/search?q=alice - Search API (heavily cached) 14 | """ 15 | 16 | from pathlib import Path 17 | 18 | from example.fastapi.app import app 19 | from premier.asgi import ASGIGateway, GatewayConfig 20 | 21 | # Load configuration 22 | config_path = Path(__file__).parent / "gateway.yaml" 23 | config = GatewayConfig.from_file(config_path) 24 | 25 | # Create Premier gateway with the FastAPI app 26 | gateway = ASGIGateway( 27 | config=config, 28 | app=app, 29 | config_file_path=str(config_path.resolve()), # Use absolute path 30 | ) 31 | 32 | # Export for uvicorn 33 | app = gateway 34 | 35 | if __name__ == "__main__": 36 | import uvicorn 37 | 38 | print("🚀 Starting Premier API Gateway Example") 39 | print("📊 Dashboard: http://localhost:8000/premier/dashboard") 40 | print("🔧 API Docs: http://localhost:8000/docs") 41 | print("💚 Health: http://localhost:8000/health") 42 | print() 43 | 44 | uvicorn.run( 45 | "main:gateway", host="0.0.0.0", port=8000, reload=True, log_level="info" 46 | ) 47 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Basic Installation 4 | 5 | Install Premier using pip: 6 | 7 | ```bash 8 | pip install premier 9 | ``` 10 | 11 | ## Optional Dependencies 12 | 13 | ### Redis Support 14 | 15 | For distributed caching and rate limiting, install with Redis support: 16 | 17 | ```bash 18 | pip install premier[redis] 19 | ``` 20 | 21 | This enables: 22 | - Distributed caching across multiple instances 23 | - Shared rate limiting across application instances 24 | - Production-ready deployments 25 | 26 | ### All Dependencies 27 | 28 | To install all optional dependencies: 29 | 30 | ```bash 31 | pip install premier[all] 32 | ``` 33 | 34 | ## Development Installation 35 | 36 | If you want to contribute to Premier or need the latest development version: 37 | 38 | ```bash 39 | git clone https://github.com/raceychan/premier.git 40 | cd premier 41 | pip install -e . 42 | ``` 43 | 44 | For development with all dependencies: 45 | 46 | ```bash 47 | pip install -e .[dev,redis] 48 | ``` 49 | 50 | ## Requirements 51 | 52 | - **Python**: >= 3.10 53 | - **Redis**: >= 5.0.3 (optional, for distributed deployments) 54 | - **PyYAML**: For YAML configuration support 55 | - **aiohttp**: Optional, for standalone mode HTTP client 56 | 57 | ## Verification 58 | 59 | Verify your installation: 60 | 61 | ```python 62 | import premier 63 | print(premier.__version__) 64 | ``` 65 | 66 | You should see the version number printed without any import errors. 67 | 68 | ## Next Steps 69 | 70 | After installation, head to the [Quick Start Guide](quickstart.md) to begin using Premier in your application. -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Premier Documentation 2 | 3 | This directory contains the source files for Premier's documentation website, built with [MkDocs Material](https://squidfunk.github.io/mkdocs-material/). 4 | 5 | ## Development 6 | 7 | ### Local Development 8 | 9 | To serve the documentation locally: 10 | 11 | ```bash 12 | uv run mkdocs serve 13 | ``` 14 | 15 | This will start a development server at `http://localhost:8000` with live reload. 16 | 17 | ### Building 18 | 19 | To build the static site: 20 | 21 | ```bash 22 | uv run mkdocs build 23 | ``` 24 | 25 | The built site will be in the `site/` directory. 26 | 27 | ### Deployment 28 | 29 | Documentation is automatically deployed to GitHub Pages when changes are pushed to the `master` branch via the `.github/workflows/docs.yml` GitHub Action. 30 | 31 | ## Structure 32 | 33 | - `index.md` - Homepage 34 | - `installation.md` - Installation guide 35 | - `quickstart.md` - Quick start tutorial 36 | - `configuration.md` - Complete configuration reference 37 | - `web-gui.md` - Web dashboard documentation 38 | - `examples.md` - Examples and use cases 39 | - `changelog.md` - Version history 40 | - `images/` - Screenshots and diagrams 41 | 42 | ## Configuration 43 | 44 | The site configuration is in `mkdocs.yml` at the project root. 45 | 46 | ## Contributing 47 | 48 | When adding new documentation: 49 | 50 | 1. Create new `.md` files in the `docs/` directory 51 | 2. Add them to the `nav` section in `mkdocs.yml` 52 | 3. Test locally with `uv run mkdocs serve` 53 | 4. Submit a pull request 54 | 55 | The documentation uses [GitHub Flavored Markdown](https://github.github.com/gfm/) with additional features from [PyMdown Extensions](https://facelessuser.github.io/pymdown-extensions/). -------------------------------------------------------------------------------- /premier/providers/redis.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | try: 5 | from redis.asyncio.client import Redis as AIORedis 6 | 7 | class AsyncRedisCache: 8 | def __init__(self, redis: AIORedis, decoder=json.loads, encoder=json.dumps): 9 | self._redis = redis 10 | self._encoder = encoder 11 | self._decoder = decoder 12 | 13 | async def get(self, key: str) -> Any: 14 | value = await self._redis.get(key) 15 | if value is None: 16 | return None 17 | try: 18 | return self._decoder(value) 19 | except (json.JSONDecodeError, TypeError): 20 | return value 21 | 22 | async def set(self, key: str, value: Any, ex: int | None = None) -> None: 23 | if isinstance(value, (str, bytes, int, float)): 24 | await self._redis.set(key, value, ex=ex) 25 | else: 26 | await self._redis.set(key, self._encoder(value), ex=ex) 27 | 28 | async def delete(self, key: str) -> None: 29 | await self._redis.delete(key) 30 | 31 | async def clear(self, keyspace: str = "") -> None: 32 | if keyspace: 33 | pattern = f"{keyspace}*" 34 | keys = await self._redis.keys(pattern) 35 | if keys: 36 | await self._redis.delete(*keys) 37 | else: 38 | await self._redis.flushdb() 39 | 40 | async def exists(self, key: str) -> bool: 41 | return bool(await self._redis.exists(key)) 42 | 43 | async def close(self) -> None: 44 | await self._redis.aclose() 45 | 46 | except ImportError: 47 | pass 48 | -------------------------------------------------------------------------------- /premier/features/throttler/errors.py: -------------------------------------------------------------------------------- 1 | from premier.errors import PremierError 2 | 3 | 4 | class ThrottlerError(PremierError): 5 | """Base class for throttler-related errors.""" 6 | pass 7 | 8 | 9 | class ArgumentMissingError(ThrottlerError): 10 | """Raised when required arguments are missing for throttler configuration.""" 11 | 12 | def __init__(self, msg: str = ""): 13 | self.msg = msg 14 | super().__init__(msg or "Required argument is missing") 15 | 16 | 17 | class UninitializedHandlerError(ThrottlerError): 18 | """Raised when throttler handler is not properly initialized.""" 19 | 20 | def __init__(self, message: str = "Throttler handler not initialized"): 21 | super().__init__(message) 22 | 23 | 24 | class QuotaExceedsError(ThrottlerError): 25 | """Raised when rate limit quota is exceeded.""" 26 | 27 | time_remains: float 28 | 29 | def __init__(self, quota: int, duration_s: int, time_remains: float): 30 | msg = f"Rate limit exceeded: {quota} requests allowed per {duration_s} seconds, retry after {time_remains:.2f}s" 31 | self.quota = quota 32 | self.duration_s = duration_s 33 | self.time_remains = time_remains 34 | super().__init__(msg) 35 | 36 | 37 | class ThrottlerConfigurationError(ThrottlerError): 38 | """Raised when throttler configuration is invalid.""" 39 | pass 40 | 41 | 42 | class ThrottlerAlgorithmError(ThrottlerError): 43 | """Raised when unsupported throttling algorithm is used.""" 44 | 45 | def __init__(self, algorithm: str): 46 | self.algorithm = algorithm 47 | super().__init__(f"Unsupported throttling algorithm: {algorithm}") 48 | 49 | 50 | class KeyMakerError(ThrottlerError): 51 | """Raised when key generation fails in throttler.""" 52 | pass 53 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from premier.providers import AsyncInMemoryCache 8 | from premier.features.throttler.handler import AsyncDefaultHandler 9 | from premier.features.throttler.throttler import Throttler 10 | 11 | 12 | def read_envs(file: Path) -> dict[str, str]: 13 | envs = { 14 | key: value 15 | for line in file.read_text().split("\n") 16 | if line and not line.startswith("#") 17 | for key, value in [line.split("=", 1)] 18 | } 19 | return envs 20 | 21 | 22 | @pytest.fixture(scope="session") 23 | def event_loop(): 24 | try: 25 | loop = asyncio.get_running_loop() 26 | except RuntimeError: 27 | loop = asyncio.new_event_loop() 28 | yield loop 29 | loop.close() 30 | 31 | 32 | @pytest.fixture(scope="session") 33 | def logger(): 34 | _logger = logging.getLogger("prmeier-test") 35 | _logger.setLevel(logging.DEBUG) 36 | 37 | console_handler = logging.StreamHandler() 38 | console_handler.setLevel(logging.DEBUG) 39 | console_format = logging.Formatter( 40 | "%(name)s | %(levelname)s | %(asctime)s | %(message)s" 41 | ) 42 | console_handler.setFormatter(console_format) 43 | 44 | _logger.addHandler(console_handler) 45 | 46 | return _logger 47 | 48 | 49 | @pytest.fixture(scope="function") 50 | def throttler(): 51 | cache = AsyncInMemoryCache() 52 | throttler = Throttler(handler=AsyncDefaultHandler(cache), keyspace="test") 53 | yield throttler 54 | 55 | 56 | @pytest.fixture 57 | async def async_handler(): 58 | cache = AsyncInMemoryCache() 59 | handler = AsyncDefaultHandler(cache) 60 | yield handler 61 | await handler.close() 62 | 63 | 64 | @pytest.fixture 65 | async def aiothrottler(async_handler: AsyncDefaultHandler): 66 | throttler = Throttler(handler=async_handler, keyspace="premier-pytest") 67 | yield throttler 68 | await throttler.clear() 69 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test cov 2 | 3 | test: 4 | uv run pytest -sv 5 | 6 | cov: 7 | uv run pytest -sv --cov-report term-missing --cov=premier tests/ 8 | 9 | # ================ CI ======================= 10 | 11 | VERSION ?= x.x.x 12 | BRANCH = version/$(VERSION) 13 | 14 | # Command definitions 15 | UV_CMD = uv run 16 | HATCH_VERSION_CMD = $(UV_CMD) hatch version 17 | CURRENT_VERSION = $(shell $(HATCH_VERSION_CMD)) 18 | 19 | # Main release target 20 | .PHONY: release check-branch check-version update-version git-commit git-merge git-tag git-push build pypi-release delete-branch new-branch 21 | 22 | release: check-branch check-version update-version git-commit git-merge git-tag git-push build 23 | 24 | # Version checking and updating 25 | check-branch: 26 | @if [ "$$(git rev-parse --abbrev-ref HEAD)" != "$(BRANCH)" ]; then \ 27 | echo "Current branch is not $(BRANCH). Switching to it..."; \ 28 | git switch -c $(BRANCH); \ 29 | echo "Switched to $(BRANCH)"; \ 30 | fi 31 | 32 | check-version: 33 | @if [ "$(CURRENT_VERSION)" = "" ]; then \ 34 | echo "Error: Unable to retrieve current version."; \ 35 | exit 1; \ 36 | fi 37 | $(call check_version_order,$(CURRENT_VERSION),$(VERSION)) 38 | 39 | update-version: 40 | @echo "Updating Pixi version to $(VERSION)..." 41 | @$(HATCH_VERSION_CMD) $(VERSION) 42 | 43 | # Git operations 44 | git-commit: 45 | @echo "Committing changes..." 46 | @git add -A 47 | @git commit -m "Release version $(VERSION)" 48 | 49 | git-merge: 50 | @echo "Merging $(BRANCH) into master..." 51 | @git checkout master 52 | @git merge "$(BRANCH)" 53 | 54 | git-tag: 55 | @echo "Tagging the release..." 56 | @git tag -a "v$(VERSION)" -m "Release version $(VERSION)" 57 | 58 | git-push: 59 | @echo "Pushing to remote repository..." 60 | @git push origin master 61 | @git push origin "v$(VERSION)" 62 | 63 | # Build and publish operations 64 | build: 65 | @echo "Building version $(VERSION)..." 66 | @uv build 67 | 68 | pypi-release: 69 | @echo "Publishing to PyPI with skip-existing flag..." 70 | @uv run twine upload dist/* --skip-existing -------------------------------------------------------------------------------- /example/gateway.yaml: -------------------------------------------------------------------------------- 1 | premier: 2 | keyspace: example-api-gateway 3 | paths: 4 | - pattern: /api/users* 5 | features: 6 | cache: 7 | expire_s: 300 8 | timeout: 9 | seconds: 10 10 | retry: 11 | max_attempts: 3 12 | wait: 1 13 | monitoring: 14 | log_threshold: 0.2 15 | - pattern: /api/products* 16 | features: 17 | cache: 18 | expire_s: 300 19 | timeout: 20 | seconds: 15 21 | monitoring: 22 | log_threshold: 0.3 23 | - pattern: /api/admin/* 24 | features: 25 | timeout: 26 | seconds: 30 27 | retry: 28 | max_attempts: 2 29 | wait: 2 30 | monitoring: 31 | log_threshold: 0.1 32 | - pattern: /api/search* 33 | features: 34 | cache: 35 | expire_s: 300 36 | timeout: 37 | seconds: 20 38 | retry: 39 | max_attempts: 2 40 | wait: 1.5 41 | monitoring: 42 | log_threshold: 0.5 43 | - pattern: /api/bulk/* 44 | features: 45 | timeout: 46 | seconds: 60 47 | retry: 48 | max_attempts: 1 49 | wait: 1 50 | monitoring: 51 | log_threshold: 1 52 | - pattern: /api/slow 53 | features: 54 | timeout: 55 | seconds: 5 56 | retry: 57 | max_attempts: 2 58 | wait: 1 59 | monitoring: 60 | log_threshold: 3 61 | - pattern: /api/unreliable 62 | features: 63 | timeout: 64 | seconds: 10 65 | retry: 66 | max_attempts: 5 67 | wait: 0.5 68 | monitoring: 69 | log_threshold: 0.1 70 | default_features: 71 | cache: 72 | expire_s: 300 73 | rate_limit: 74 | quota: 60 75 | duration: 60 76 | algorithm: sliding_window 77 | timeout: 78 | seconds: 10 79 | retry: 80 | max_attempts: 3 81 | wait: 1 82 | monitoring: 83 | log_threshold: 1 84 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "premier" 3 | dynamic = ["version"] 4 | description = "an extendable toolbox for scalable apis" 5 | dependencies = [ 6 | "pyyaml>=6.0.2", 7 | "typing-extensions>=4.14.0", 8 | ] 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "Intended Audience :: Information Technology", 14 | "Intended Audience :: System Administrators", 15 | "Intended Audience :: Developers", 16 | "Operating System :: OS Independent", 17 | "Topic :: Software Development :: Libraries :: Application Frameworks", 18 | "Topic :: Software Development :: Libraries :: Python Modules", 19 | "Topic :: Software Development :: Libraries", 20 | "Topic :: Software Development", 21 | "Typing :: Typed", 22 | "License :: OSI Approved :: MIT License", 23 | "Programming Language :: Python :: 3 :: Only", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | ] 29 | 30 | [project.optional-dependencies] 31 | redis = ["redis"] 32 | aiohttp = ["aiohttp"] 33 | uvicorn = ["uvicorn"] 34 | 35 | [dependency-groups] 36 | dev = [ 37 | "pytest>=8.3.5", 38 | "pytest-asyncio>=0.25.3", 39 | "pytest-cov>=6.0.0", 40 | "hatch>=1.14.0", 41 | "twine>=6.1.0", 42 | "uvicorn>=0.34.3", 43 | "fastapi>=0.115.12", 44 | "diagrams>=0.24.4", 45 | "mkdocs-material[imaging]>=9.6.14", 46 | "mkdocs-git-revision-date-localized-plugin>=1.4.7", 47 | "mkdocs-minify-plugin>=0.8.0", 48 | "pillow>=10.4.0", 49 | "cairosvg>=2.8.2", 50 | ] 51 | 52 | [tool.hatch.version] 53 | path = "premier/__init__.py" 54 | 55 | [build-system] 56 | build-backend = "hatchling.build" 57 | requires = ["hatchling"] 58 | 59 | [tool.pytest.ini_options] 60 | addopts = "--strict-markers --capture=no" 61 | asyncio_mode = "auto" 62 | filterwarnings = [ 63 | "ignore::DeprecationWarning", 64 | "ignore::UserWarning:lihil.signature.parser", 65 | ] 66 | markers = [ 67 | "integration_test: marks tests as slow integration tests (deselect with '-m \"not integration_test\"')", 68 | "debug: marks tests as debug tests (deselect with '-m \"not debug\"')", 69 | "benchmark: marks tests as benchmark tests (deselect with '-m \"not benchmark\"')", 70 | ] 71 | testpaths = ["tests"] 72 | -------------------------------------------------------------------------------- /premier/providers/memory.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | from typing import Any, Dict 4 | 5 | class AsyncInMemoryCache: 6 | def __init__(self, timer_func=time.time): 7 | self._storage: Dict[str, Any] = {} 8 | self._expiry: Dict[str, float] = {} 9 | self._timer_func = timer_func 10 | self._lock = asyncio.Lock() 11 | 12 | async def get(self, key: str) -> Any: 13 | async with self._lock: 14 | # Check if key has expired 15 | if key in self._expiry: 16 | if self._timer_func() > self._expiry[key]: 17 | # Key has expired, remove it 18 | self._storage.pop(key, None) 19 | self._expiry.pop(key, None) 20 | return None 21 | return self._storage.get(key) 22 | 23 | async def set(self, key: str, value: Any, ex: int | None = None) -> None: 24 | async with self._lock: 25 | self._storage[key] = value 26 | if ex is not None: 27 | self._expiry[key] = self._timer_func() + ex 28 | else: 29 | # Remove expiry if it exists 30 | self._expiry.pop(key, None) 31 | 32 | async def delete(self, key: str) -> None: 33 | async with self._lock: 34 | self._storage.pop(key, None) 35 | self._expiry.pop(key, None) 36 | 37 | async def clear(self, keyspace: str = "") -> None: 38 | async with self._lock: 39 | if not keyspace: 40 | self._storage.clear() 41 | self._expiry.clear() 42 | else: 43 | keys = [k for k in self._storage if k.startswith(keyspace)] 44 | for k in keys: 45 | self._storage.pop(k, None) 46 | self._expiry.pop(k, None) 47 | 48 | async def exists(self, key: str) -> bool: 49 | async with self._lock: 50 | # Check if key has expired first 51 | if key in self._expiry: 52 | if self._timer_func() > self._expiry[key]: 53 | # Key has expired, remove it 54 | self._storage.pop(key, None) 55 | self._expiry.pop(key, None) 56 | return False 57 | return key in self._storage 58 | 59 | async def close(self) -> None: 60 | pass 61 | -------------------------------------------------------------------------------- /premier/asgi/loadbalancer.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import List, Protocol 3 | 4 | 5 | class ILoadBalancer(Protocol): 6 | """Protocol for load balancer implementations.""" 7 | 8 | def choose(self) -> str: 9 | """Choose a server from the available servers.""" 10 | ... 11 | 12 | 13 | class RandomLoadBalancer: 14 | """Random load balancer implementation.""" 15 | 16 | def __init__(self, servers: List[str]): 17 | """ 18 | Initialize with a list of server URLs. 19 | 20 | Args: 21 | servers: List of server URLs to load balance between 22 | """ 23 | if not servers: 24 | raise ValueError("At least one server must be provided") 25 | self.servers = servers 26 | 27 | def choose(self) -> str: 28 | """Choose a random server from the available servers.""" 29 | return random.choice(self.servers) 30 | 31 | 32 | class RoundRobinLoadBalancer: 33 | """Round robin load balancer implementation.""" 34 | 35 | def __init__(self, servers: List[str]): 36 | """ 37 | Initialize with a list of server URLs. 38 | 39 | Args: 40 | servers: List of server URLs to load balance between 41 | """ 42 | if not servers: 43 | raise ValueError("At least one server must be provided") 44 | self.servers = servers 45 | self._current_index = 0 46 | 47 | def choose(self) -> str: 48 | """Choose the next server in round robin fashion.""" 49 | server = self.servers[self._current_index] 50 | self._current_index = (self._current_index + 1) % len(self.servers) 51 | return server 52 | 53 | 54 | def create_random_load_balancer(servers: List[str]) -> ILoadBalancer: 55 | """ 56 | Factory function to create a RandomLoadBalancer. 57 | 58 | Args: 59 | servers: List of server URLs 60 | 61 | Returns: 62 | ILoadBalancer instance 63 | """ 64 | return RandomLoadBalancer(servers) 65 | 66 | 67 | def create_round_robin_load_balancer(servers: List[str]) -> ILoadBalancer: 68 | """ 69 | Factory function to create a RoundRobinLoadBalancer. 70 | 71 | Args: 72 | servers: List of server URLs 73 | 74 | Returns: 75 | ILoadBalancer instance 76 | """ 77 | return RoundRobinLoadBalancer(servers) -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Premier Documentation 2 | site_description: Premier - Versatile Python API Gateway Toolkit 3 | site_author: raceychan 4 | site_url: https://raceychan.github.io/premier 5 | 6 | repo_name: raceychan/premier 7 | repo_url: https://github.com/raceychan/premier 8 | edit_uri: edit/master/docs/ 9 | 10 | theme: 11 | name: material 12 | features: 13 | - navigation.tabs 14 | - navigation.sections 15 | - toc.integrate 16 | - navigation.top 17 | - search.suggest 18 | - search.highlight 19 | - content.tabs.link 20 | - content.code.annotation 21 | - content.code.copy 22 | - content.code.select 23 | - content.action.edit 24 | - content.action.view 25 | language: en 26 | palette: 27 | - scheme: default 28 | toggle: 29 | icon: material/toggle-switch-off-outline 30 | name: Switch to dark mode 31 | primary: blue 32 | accent: teal 33 | - scheme: slate 34 | toggle: 35 | icon: material/toggle-switch 36 | name: Switch to light mode 37 | primary: blue 38 | accent: lime 39 | 40 | plugins: 41 | - social 42 | - search 43 | - git-revision-date-localized: 44 | type: date 45 | - minify: 46 | minify_html: true 47 | 48 | markdown_extensions: 49 | - pymdownx.highlight: 50 | anchor_linenums: true 51 | line_spans: __span 52 | pygments_lang_class: true 53 | - pymdownx.inlinehilite 54 | - pymdownx.snippets 55 | - pymdownx.superfences 56 | - pymdownx.tabbed: 57 | alternate_style: true 58 | - pymdownx.details 59 | - pymdownx.mark 60 | - admonition 61 | - attr_list 62 | - def_list 63 | - footnotes 64 | - md_in_html 65 | - toc: 66 | permalink: true 67 | - tables 68 | - pymdownx.arithmatex: 69 | generic: true 70 | - pymdownx.emoji: 71 | emoji_index: !!python/name:material.extensions.emoji.twemoji 72 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 73 | 74 | nav: 75 | - Home: index.md 76 | - Getting Started: 77 | - Installation: installation.md 78 | - Quick Start: quickstart.md 79 | - Configuration: configuration.md 80 | - Web Dashboard: web-gui.md 81 | - Examples: examples.md 82 | - Changelog: changelog.md 83 | 84 | extra: 85 | social: 86 | - icon: fontawesome/brands/github 87 | link: https://github.com/raceychan/premier 88 | - icon: fontawesome/brands/python 89 | link: https://pypi.org/project/premier/ 90 | 91 | copyright: Copyright © 2024 raceychan -------------------------------------------------------------------------------- /docs/web-gui.md: -------------------------------------------------------------------------------- 1 | # Web GUI Dashboard 2 | 3 | Premier provides a built-in web dashboard for monitoring and managing your API gateway in real-time. 4 | 5 | 6 | 7 | 8 | 9 | 10 | ## Features 11 | 12 | - **Real-time Monitoring** - Live request/response metrics and performance data 13 | ![stats](/docs/images/dashboard.png) 14 | - **Configuration Management** - View and update gateway configuration 15 | ![config](/docs/images/dashboard_config.png) 16 | - **Request Analytics** - Detailed request logs and statistics 17 | - **Cache Management** - Monitor cache hit rates and manage cached data 18 | - **Rate Limiting Dashboard** - View current rate limits and usage 19 | - **Health Monitoring** - System health and uptime statistics 20 | 21 | ## Accessing the Dashboard 22 | 23 | The dashboard is automatically available at `/premier/dashboard` when you run your Premier gateway: 24 | 25 | ``` 26 | http://localhost:8000/premier/dashboard 27 | ``` 28 | 29 | ## Configuration 30 | 31 | The dashboard is enabled by default. You can customize its behavior in your configuration: 32 | 43 | 44 | ## Dashboard Sections 45 | 46 | ### Overview 47 | - Real-time request rate 48 | - Average response time 49 | - Error rate 50 | - Cache hit ratio 51 | 52 | ### Requests 53 | - Live request log 54 | - Response time distribution 55 | - Status code breakdown 56 | - Top endpoints by traffic 57 | 58 | ### Configuration 59 | - Current gateway configuration 60 | - Path-based policies 61 | - Feature settings 62 | - Hot reload configuration changes 63 | 64 | ### Cache 65 | - Cache statistics 66 | - Hit/miss ratios 67 | - Cache size and memory usage 68 | - Manual cache management 69 | 70 | ### Rate Limiting 71 | - Current rate limit status 72 | - Usage by endpoint 73 | - Rate limit violations 74 | - Algorithm performance 75 | 76 | 88 | 89 | ## API Endpoints 90 | 91 | The dashboard uses these API endpoints (also available for programmatic access): 92 | 93 | - `GET /premier/api/stats` - Current statistics 94 | - `GET /premier/api/config` - Current configuration 95 | - `POST /premier/api/config` - Update configuration 96 | - `GET /premier/api/cache` - Cache statistics 97 | - `DELETE /premier/api/cache` - Clear cache 98 | - `GET /premier/api/requests` - Request logs 99 | 100 | ## Custom Styling 101 | 102 | You can customize the dashboard appearance by overriding CSS variables or providing custom themes. -------------------------------------------------------------------------------- /docs/web-gui.md.bak: -------------------------------------------------------------------------------- 1 | # Web GUI Dashboard 2 | 3 | Premier provides a built-in web dashboard for monitoring and managing your API gateway in real-time. 4 | 5 | 6 | 7 | 8 | 9 | 10 | ## Features 11 | 12 | - **Real-time Monitoring** - Live request/response metrics and performance data 13 | ![stats](/docs/images/dashboard.png) 14 | - **Configuration Management** - View and update gateway configuration 15 | ![config](/docs/images/dashboard_config.png) 16 | - **Request Analytics** - Detailed request logs and statistics 17 | - **Cache Management** - Monitor cache hit rates and manage cached data 18 | - **Rate Limiting Dashboard** - View current rate limits and usage 19 | - **Health Monitoring** - System health and uptime statistics 20 | 21 | ## Accessing the Dashboard 22 | 23 | The dashboard is automatically available at `/premier/dashboard` when you run your Premier gateway: 24 | 25 | ``` 26 | http://localhost:8000/premier/dashboard 27 | ``` 28 | 29 | ## Configuration 30 | 31 | The dashboard is enabled by default. You can customize its behavior in your configuration: 32 | 43 | 44 | ## Dashboard Sections 45 | 46 | ### Overview 47 | - Real-time request rate 48 | - Average response time 49 | - Error rate 50 | - Cache hit ratio 51 | 52 | ### Requests 53 | - Live request log 54 | - Response time distribution 55 | - Status code breakdown 56 | - Top endpoints by traffic 57 | 58 | ### Configuration 59 | - Current gateway configuration 60 | - Path-based policies 61 | - Feature settings 62 | - Hot reload configuration changes 63 | 64 | ### Cache 65 | - Cache statistics 66 | - Hit/miss ratios 67 | - Cache size and memory usage 68 | - Manual cache management 69 | 70 | ### Rate Limiting 71 | - Current rate limit status 72 | - Usage by endpoint 73 | - Rate limit violations 74 | - Algorithm performance 75 | 76 | 88 | 89 | ## API Endpoints 90 | 91 | The dashboard uses these API endpoints (also available for programmatic access): 92 | 93 | - `GET /premier/api/stats` - Current statistics 94 | - `GET /premier/api/config` - Current configuration 95 | - `POST /premier/api/config` - Update configuration 96 | - `GET /premier/api/cache` - Cache statistics 97 | - `DELETE /premier/api/cache` - Clear cache 98 | - `GET /premier/api/requests` - Request logs 99 | 100 | ## Custom Styling 101 | 102 | You can customize the dashboard appearance by overriding CSS variables or providing custom themes. -------------------------------------------------------------------------------- /tests/test_retry_simple.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock, patch 2 | 3 | import pytest 4 | 5 | from premier.features.retry import retry 6 | 7 | 8 | class CustomException(Exception): 9 | pass 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_retry_basic_success(): 14 | """Test basic retry functionality""" 15 | mock_func = AsyncMock(return_value="success") 16 | 17 | with patch("asyncio.sleep"): 18 | 19 | @retry(max_attempts=3, wait=1) 20 | async def test_func(): 21 | return await mock_func() 22 | 23 | result = await test_func() 24 | 25 | assert result == "success" 26 | assert mock_func.call_count == 1 27 | 28 | 29 | @pytest.mark.asyncio 30 | async def test_retry_with_failures(): 31 | """Test retry with failures then success""" 32 | mock_func = AsyncMock(side_effect=[Exception("fail"), "success"]) 33 | 34 | with patch("asyncio.sleep"): 35 | 36 | @retry(max_attempts=3, wait=1) 37 | async def test_func(): 38 | return await mock_func() 39 | 40 | result = await test_func() 41 | 42 | assert result == "success" 43 | assert mock_func.call_count == 2 44 | 45 | 46 | @pytest.mark.asyncio 47 | async def test_retry_max_attempts(): 48 | """Test retry reaches max attempts""" 49 | mock_func = AsyncMock(side_effect=CustomException("always fails")) 50 | 51 | with patch("asyncio.sleep"): 52 | 53 | @retry(max_attempts=2, wait=1) 54 | async def test_func(): 55 | return await mock_func() 56 | 57 | with pytest.raises(CustomException): 58 | await test_func() 59 | 60 | assert mock_func.call_count == 2 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_retry_wait_strategies(): 65 | """Test different wait strategies""" 66 | mock_func = AsyncMock(side_effect=[Exception("fail"), "success"]) 67 | 68 | # Test list wait strategy 69 | with patch("asyncio.sleep") as mock_sleep: 70 | 71 | @retry(max_attempts=2, wait=[1, 2]) 72 | async def test_func(): 73 | return await mock_func() 74 | 75 | result = await test_func() 76 | assert result == "success" 77 | mock_sleep.assert_called_once_with(1) 78 | 79 | 80 | @pytest.mark.asyncio 81 | async def test_retry_callable_wait(): 82 | """Test callable wait strategy""" 83 | mock_func = AsyncMock(side_effect=[Exception("fail"), "success"]) 84 | 85 | def backoff(attempt: int): 86 | return attempt + 1 87 | 88 | with patch("asyncio.sleep") as mock_sleep: 89 | 90 | @retry(max_attempts=2, wait=backoff) 91 | async def test_func(): 92 | return await mock_func() 93 | 94 | result = await test_func() 95 | assert result == "success" 96 | mock_sleep.assert_called_once_with(1) # backoff(0) = 1 97 | 98 | 99 | @pytest.mark.asyncio 100 | async def test_retry_specific_exceptions(): 101 | """Test retry only catches specified exceptions""" 102 | 103 | @retry(max_attempts=3, wait=1, exceptions=(ValueError,)) 104 | async def test_func(): 105 | raise TypeError("should not retry") 106 | 107 | with pytest.raises(TypeError): 108 | await test_func() 109 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Premier Gateway Example 2 | 3 | This example demonstrates Premier API Gateway with a comprehensive dashboard. 4 | 5 | ## Quick Start 6 | 7 | 1. **Install dependencies:** 8 | ```bash 9 | pip install fastapi uvicorn pyyaml 10 | ``` 11 | 12 | 2. **Run the application:** 13 | ```bash 14 | cd example 15 | uvicorn main:gateway --host 0.0.0.0 --port 8000 --reload 16 | ``` 17 | 18 | 3. **Visit the dashboard:** 19 | ``` 20 | http://localhost:8000/premier/dashboard 21 | ``` 22 | 23 | ## What's Included 24 | 25 | ### FastAPI Application (`app.py`) 26 | - **User Management**: `/api/users*` - CRUD operations with mock data 27 | - **Product Catalog**: `/api/products*` - Product browsing endpoints 28 | - **Admin Operations**: `/api/admin/*` - Restricted admin functionality 29 | - **Search**: `/api/search` - Expensive search operation (good for caching) 30 | - **Test Endpoints**: 31 | - `/api/slow` - Tests timeout handling 32 | - `/api/unreliable` - Tests retry logic 33 | - `/api/bulk/*` - Tests rate limiting 34 | 35 | ### Gateway Configuration (`gateway.yaml`) 36 | - **Path-specific policies** with different caching, rate limiting, and timeout strategies 37 | - **Caching**: 5-30 minutes TTL based on endpoint characteristics 38 | - **Rate Limiting**: Different algorithms (sliding window, token bucket, fixed window) 39 | - **Timeout Protection**: 5-60 seconds based on operation complexity 40 | - **Retry Logic**: Smart retry strategies for different endpoint types 41 | - **Monitoring**: Performance tracking with configurable thresholds 42 | 43 | ### Premier Dashboard Features 44 | - **Real-time Statistics**: Request counts, cache hit rates, response times 45 | - **Recent Requests**: Live request monitoring with status codes and timings 46 | - **Active Policies**: View configured policies and their request counts 47 | - **Configuration Editor**: Edit and validate YAML configuration in real-time 48 | - **Auto-refresh**: Dashboard updates every 30 seconds 49 | 50 | ## Testing the Features 51 | 52 | ### Cache Testing 53 | ```bash 54 | # First request (cache miss) 55 | curl http://localhost:8000/api/users 56 | # Second request (cache hit - faster) 57 | curl http://localhost:8000/api/users 58 | ``` 59 | 60 | ### Rate Limiting Testing 61 | ```bash 62 | # Spam admin endpoint to trigger rate limiting 63 | for i in {1..15}; do curl http://localhost:8000/api/admin/stats; done 64 | ``` 65 | 66 | ### Timeout Testing 67 | ```bash 68 | # Some requests will timeout after 5 seconds 69 | curl http://localhost:8000/api/slow 70 | ``` 71 | 72 | ### Retry Testing 73 | ```bash 74 | # Will retry on failures automatically 75 | curl http://localhost:8000/api/unreliable 76 | ``` 77 | 78 | ## Dashboard URLs 79 | 80 | - **Main Dashboard**: `http://localhost:8000/premier/dashboard` 81 | - **Stats API**: `http://localhost:8000/premier/dashboard/api/stats` 82 | - **Policies API**: `http://localhost:8000/premier/dashboard/api/policies` 83 | - **Config API**: `http://localhost:8000/premier/dashboard/api/config` 84 | 85 | ## Configuration Management 86 | 87 | The dashboard allows you to: 88 | 1. **View** current YAML configuration 89 | 2. **Edit** configuration in real-time 90 | 3. **Validate** configuration before saving 91 | 4. **Reload** configuration from file 92 | 93 | Changes take effect immediately without restart! 94 | 95 | ## Performance Monitoring 96 | 97 | Watch the dashboard to see: 98 | - Request patterns and performance 99 | - Cache effectiveness 100 | - Rate limiting in action 101 | - Error rates and retry behavior 102 | - Configuration impact on performance -------------------------------------------------------------------------------- /tests/test_timeout_simple.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import pytest 3 | from unittest.mock import Mock 4 | 5 | from premier.features.timer import timeout 6 | from premier.features.timer_errors import TimeoutError as PremierTimeoutError 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_timeout_success(): 11 | """Test timeout allows fast operations""" 12 | @timeout(1) 13 | async def fast_func(): 14 | await asyncio.sleep(0.1) 15 | return "completed" 16 | 17 | result = await fast_func() 18 | assert result == "completed" 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_timeout_exceeds(): 23 | """Test timeout raises TimeoutError for slow operations""" 24 | @timeout(1) # 1 second timeout 25 | async def slow_func(): 26 | await asyncio.sleep(2) # 2 second operation 27 | return "should not reach" 28 | 29 | with pytest.raises(PremierTimeoutError): 30 | await slow_func() 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_timeout_with_logger(): 35 | """Test timeout with custom logger""" 36 | mock_logger = Mock() 37 | mock_logger.exception = Mock() 38 | 39 | @timeout(1, logger=mock_logger) 40 | async def slow_func(): 41 | await asyncio.sleep(2) 42 | return "result" 43 | 44 | with pytest.raises(PremierTimeoutError): 45 | await slow_func() 46 | 47 | # The logger call may be implementation-specific, so we just check it's available 48 | assert hasattr(mock_logger, 'exception') 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_timeout_with_args(): 53 | """Test timeout preserves function arguments""" 54 | @timeout(1) 55 | async def func_with_args(a, b, c=None): 56 | await asyncio.sleep(0.1) 57 | return f"{a}-{b}-{c}" 58 | 59 | result = await func_with_args("x", "y", c="z") 60 | assert result == "x-y-z" 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_timeout_exception_before_timeout(): 65 | """Test function exception is raised before timeout""" 66 | @timeout(2) # Long timeout 67 | async def failing_func(): 68 | await asyncio.sleep(0.1) 69 | raise ValueError("function error") 70 | 71 | with pytest.raises(ValueError, match="function error"): 72 | await failing_func() 73 | 74 | 75 | def test_timeout_sync_function_raises_error(): 76 | """Test timeout decorator raises ValueError for sync functions""" 77 | with pytest.raises(ValueError, match="timeout decorator only supports async functions"): 78 | @timeout(1) 79 | def sync_func(): 80 | return "sync result" 81 | 82 | 83 | def test_timeout_sync_function_with_logger_raises_error(): 84 | """Test timeout decorator with logger raises ValueError for sync functions""" 85 | mock_logger = Mock() 86 | 87 | with pytest.raises(ValueError, match="timeout decorator only supports async functions"): 88 | @timeout(1, logger=mock_logger) 89 | def sync_func_with_logger(): 90 | return "sync result" 91 | 92 | 93 | @pytest.mark.asyncio 94 | async def test_timeout_zero_seconds(): 95 | """Test timeout with zero seconds""" 96 | @timeout(0) 97 | async def instant_func(): 98 | return "instant" 99 | 100 | with pytest.raises(PremierTimeoutError): 101 | await instant_func() 102 | 103 | 104 | @pytest.mark.asyncio 105 | async def test_timeout_negative_seconds(): 106 | """Test timeout with negative seconds""" 107 | @timeout(-1) 108 | async def negative_timeout_func(): 109 | return "should timeout immediately" 110 | 111 | with pytest.raises(PremierTimeoutError): 112 | await negative_timeout_func() -------------------------------------------------------------------------------- /premier/features/cache.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from functools import wraps 3 | from typing import Any, Awaitable, Callable, ParamSpec, TypeVar 4 | 5 | from premier.providers.interace import AsyncCacheProvider 6 | 7 | P = ParamSpec("P") 8 | R = TypeVar("R") 9 | KeyMaker = Callable[..., str] 10 | 11 | 12 | def _make_cache_key( 13 | func: Callable, 14 | keyspace: str, 15 | args: tuple, 16 | kwargs: dict, 17 | cache_key: str | KeyMaker | None = None, 18 | ) -> str: 19 | """Generate cache key for function call""" 20 | if cache_key is None: 21 | # Default key generation: function name + args + kwargs 22 | func_name = f"{func.__module__}.{func.__name__}" 23 | args_str = "_".join(str(arg) for arg in args) 24 | kwargs_str = "_".join(f"{k}={v}" for k, v in sorted(kwargs.items())) 25 | key_parts = [keyspace, func_name, args_str, kwargs_str] 26 | return ":".join(filter(None, key_parts)) 27 | elif isinstance(cache_key, str): 28 | return f"{keyspace}:{cache_key}" if keyspace else cache_key 29 | else: 30 | # cache_key is a function 31 | user_key = cache_key(*args, **kwargs) 32 | return f"{keyspace}:{user_key}" if keyspace else user_key 33 | 34 | 35 | class Cache: 36 | """ 37 | Async-only cache decorator for caching function results 38 | """ 39 | 40 | def __init__( 41 | self, 42 | cache_provider: AsyncCacheProvider, 43 | keyspace: str = "premier:cache", 44 | ): 45 | self._cache_provider = cache_provider 46 | self._keyspace = keyspace 47 | 48 | async def clear(self, keyspace: str | None = None): 49 | if keyspace is None: 50 | keyspace = self._keyspace 51 | await self._cache_provider.clear(keyspace) 52 | 53 | def cache( 54 | self, 55 | expire_s: int | None = None, 56 | cache_key: str | KeyMaker | None = None, 57 | encoder: Callable[[R], Any] | None = None, 58 | ) -> Callable[[Callable[P, R]], Callable[P, Awaitable[R | None]]]: 59 | """ 60 | Cache decorator 61 | 62 | Args: 63 | expire_s: TTL in seconds (None means no expiration) 64 | cache_key: Either a string key or function that generates key from args/kwargs 65 | encoder: Function to encode the result before caching (optional) 66 | """ 67 | 68 | def wrapper(func: Callable[P, R]) -> Callable[P, Awaitable[R | None]]: 69 | @wraps(func) 70 | async def ainner(*args: P.args, **kwargs: P.kwargs) -> R | None: 71 | 72 | # Generate cache key 73 | key = _make_cache_key( 74 | func=func, 75 | keyspace=self._keyspace, 76 | args=args, 77 | kwargs=kwargs, 78 | cache_key=cache_key, 79 | ) 80 | 81 | # Try to get from cache first 82 | cached_data = await self._cache_provider.get(key) 83 | if cached_data is not None: 84 | # Cache hit - return the cached value 85 | return cached_data 86 | 87 | # Cache miss, execute function 88 | if inspect.iscoroutinefunction(func): 89 | result = await func(*args, **kwargs) 90 | else: 91 | result = func(*args, **kwargs) 92 | 93 | # Encode result if encoder provided 94 | cache_value = encoder(result) if encoder else result 95 | 96 | # Store in cache with TTL 97 | await self._cache_provider.set(key, cache_value, ex=expire_s) 98 | return result 99 | 100 | return ainner 101 | 102 | return wrapper 103 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | .vscode 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | # pixi environments 163 | .pixi 164 | -------------------------------------------------------------------------------- /example_asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example usage of Premier ASGI Gateway. 3 | 4 | This example demonstrates how to configure and use the ASGI Gateway 5 | with different features applied to different paths. 6 | """ 7 | 8 | import asyncio 9 | from premier.asgi import create_gateway, GatewayConfig 10 | 11 | 12 | # Example downstream ASGI application 13 | async def simple_app(scope, receive, send): 14 | """Simple ASGI app for demonstration.""" 15 | if scope["type"] == "http": 16 | path = scope.get("path", "/") 17 | method = scope.get("method", "GET") 18 | 19 | response_body = f"Hello from {method} {path}".encode() 20 | 21 | await send({ 22 | "type": "http.response.start", 23 | "status": 200, 24 | "headers": [[b"content-type", b"text/plain"]], 25 | }) 26 | await send({ 27 | "type": "http.response.body", 28 | "body": response_body, 29 | }) 30 | 31 | 32 | # Gateway configuration 33 | config: GatewayConfig = { 34 | "keyspace": "example-gateway", 35 | "paths": [ 36 | { 37 | "pattern": "/api/users/*", 38 | "features": { 39 | "rate_limit": { 40 | "algorithm": "sliding_window", 41 | "quota": 100, 42 | "duration": 60, # 100 requests per minute 43 | }, 44 | "cache": { 45 | "expire_s": 300, # 5 minutes 46 | }, 47 | "timeout": 10.0, # 10 seconds 48 | "monitoring": { 49 | "log_threshold": 0.1, # Log requests > 100ms 50 | }, 51 | }, 52 | }, 53 | { 54 | "pattern": "/api/admin/*", 55 | "features": { 56 | "rate_limit": { 57 | "algorithm": "token_bucket", 58 | "quota": 10, 59 | "duration": 60, # 10 requests per minute with bursts 60 | }, 61 | "retry": { 62 | "max_attempts": 3, 63 | "wait": 1.0, 64 | }, 65 | "timeout": 30.0, # 30 seconds for admin operations 66 | }, 67 | }, 68 | { 69 | "pattern": "/api/public/*", 70 | "features": { 71 | "rate_limit": { 72 | "algorithm": "fixed_window", 73 | "quota": 1000, 74 | "duration": 3600, # 1000 requests per hour 75 | }, 76 | "cache": { 77 | "expire_s": 1800, # 30 minutes 78 | }, 79 | }, 80 | }, 81 | { 82 | "pattern": "^/health$", # Exact match using regex 83 | "features": { 84 | "monitoring": { 85 | "log_threshold": 0.05, # Log all health checks > 50ms 86 | }, 87 | }, 88 | }, 89 | ], 90 | "default_features": { 91 | "timeout": 5.0, # Default 5-second timeout for unmatched paths 92 | "monitoring": { 93 | "log_threshold": 0.5, # Log slow requests by default 94 | }, 95 | }, 96 | } 97 | 98 | 99 | async def run_example(): 100 | """Run the ASGI gateway example.""" 101 | # Create the gateway 102 | gateway = create_gateway(config, simple_app) 103 | 104 | # In a real application, you'd serve this with an ASGI server like uvicorn: 105 | # uvicorn example_asgi:gateway --host 0.0.0.0 --port 8000 106 | 107 | print("ASGI Gateway created successfully!") 108 | print(f"Configuration loaded with {len(config['paths'])} path patterns") 109 | 110 | # Simulate some requests (for demonstration) 111 | test_paths = [ 112 | "/api/users/123", 113 | "/api/admin/settings", 114 | "/api/public/catalog", 115 | "/health", 116 | "/unknown/path", 117 | ] 118 | 119 | for path in test_paths: 120 | features = gateway._match_path(path) 121 | print(f"Path '{path}' -> Features: {list(features.keys()) if features else 'default'}") 122 | 123 | # Clean up 124 | await gateway.close() 125 | 126 | 127 | if __name__ == "__main__": 128 | asyncio.run(run_example()) -------------------------------------------------------------------------------- /premier/features/timer.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from asyncio import wait_for as await_for 3 | from contextlib import contextmanager 4 | from functools import wraps 5 | from inspect import iscoroutinefunction 6 | from time import perf_counter 7 | from typing import Any, Awaitable, Callable, Optional, Protocol, cast, overload 8 | 9 | from premier.interface import FlexDecor, P, R 10 | from premier.features.timer_errors import TimeoutError as PremierTimeoutError 11 | 12 | 13 | class ILogger(Protocol): 14 | def exception(self, msg: str): ... 15 | 16 | def info(self, msg: str): ... 17 | 18 | 19 | CustomLogger = Callable[[float], None] 20 | 21 | ValidLogger = ILogger | CustomLogger 22 | 23 | 24 | def timeit( 25 | func__: Callable[P, R] | None = None, 26 | *, 27 | logger: Optional[ValidLogger] = None, 28 | precision: int = 2, 29 | log_threshold: float = 0.1, 30 | with_args: bool = False, 31 | show_fino: bool = True, 32 | ) -> FlexDecor[P, R]: 33 | 34 | @overload 35 | def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, R]: ... 36 | 37 | @overload 38 | def decorator(func: Callable[P, R]) -> Callable[P, R]: ... 39 | 40 | def decorator( 41 | func: Callable[P, R] | Callable[P, Awaitable[R]], 42 | ) -> Callable[P, R] | Callable[P, Awaitable[R]]: 43 | def build_logmsg( 44 | timecost: float, 45 | func_args: tuple[Any, ...], 46 | func_kwargs: dict[Any, Any], 47 | ): 48 | 49 | func_repr = func.__qualname__ 50 | if with_args: 51 | arg_repr = ", ".join(f"{arg}" for arg in func_args) 52 | kwargs_repr = ", ".join(f"{k}={v}" for k, v in func_kwargs.items()) 53 | func_repr = f"{func_repr}({arg_repr}, {kwargs_repr})" 54 | 55 | msg = f"{func_repr} {timecost}s" 56 | 57 | if show_fino: 58 | func_code = func.__code__ 59 | location = f"{func_code.co_filename}({func_code.co_firstlineno})" 60 | msg = f"{location} {msg}" 61 | 62 | return msg 63 | 64 | @contextmanager 65 | def log_callback(*args: P.args, **kwargs: P.kwargs): 66 | pre = perf_counter() 67 | yield 68 | aft = perf_counter() 69 | timecost = round(aft - pre, precision) 70 | 71 | if timecost < log_threshold: 72 | return 73 | 74 | if logger: 75 | if callable(logger): 76 | logger(timecost) 77 | else: 78 | logger.info(build_logmsg(timecost, args, kwargs)) 79 | else: 80 | print(build_logmsg(timecost, args, kwargs)) 81 | 82 | @wraps(func) 83 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: 84 | f = cast(Callable[P, R], func) 85 | with log_callback(*args, **kwargs): 86 | res = f(*args, **kwargs) 87 | return res 88 | 89 | @wraps(func) 90 | async def awrapper(*args: P.args, **kwargs: P.kwargs) -> R: 91 | f = cast(Callable[P, Awaitable[R]], func) 92 | with log_callback(*args, **kwargs): 93 | res = await f(*args, **kwargs) 94 | return res 95 | 96 | if iscoroutinefunction(func): 97 | return awrapper 98 | else: 99 | return wrapper 100 | 101 | if func__ is None: 102 | return decorator 103 | else: 104 | return decorator(func__) 105 | 106 | 107 | def timeout(seconds: int, *, logger: ILogger | None = None): 108 | def decor_dispatch(func: Callable[..., Any]): 109 | if not iscoroutinefunction(func): 110 | raise ValueError("timeout decorator only supports async functions") 111 | 112 | async def async_timeout(*args: Any, **kwargs: Any): 113 | coro = func(*args, **kwargs) 114 | try: 115 | res = await await_for(coro, seconds) 116 | except asyncio.TimeoutError as te: 117 | timeout_error = PremierTimeoutError(seconds, func.__name__) 118 | if logger: 119 | logger.exception(str(timeout_error)) 120 | raise timeout_error from te 121 | return res 122 | 123 | return async_timeout 124 | 125 | return decor_dispatch 126 | -------------------------------------------------------------------------------- /tests/test_loadbalancer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from premier.asgi.loadbalancer import ( 3 | RandomLoadBalancer, 4 | RoundRobinLoadBalancer, 5 | create_random_load_balancer, 6 | create_round_robin_load_balancer, 7 | ) 8 | 9 | 10 | class TestRandomLoadBalancer: 11 | def test_initialization_with_servers(self): 12 | servers = ["http://server1.com", "http://server2.com"] 13 | lb = RandomLoadBalancer(servers) 14 | assert lb.servers == servers 15 | 16 | def test_initialization_without_servers_raises_error(self): 17 | with pytest.raises(ValueError, match="At least one server must be provided"): 18 | RandomLoadBalancer([]) 19 | 20 | def test_choose_returns_server_from_list(self): 21 | servers = ["http://server1.com", "http://server2.com", "http://server3.com"] 22 | lb = RandomLoadBalancer(servers) 23 | 24 | # Test multiple calls to ensure it's choosing from the list 25 | chosen_servers = set() 26 | for _ in range(50): # Run enough times to likely hit all servers 27 | chosen = lb.choose() 28 | assert chosen in servers 29 | chosen_servers.add(chosen) 30 | 31 | # Should have chosen at least one server (randomness makes it hard to guarantee all) 32 | assert len(chosen_servers) >= 1 33 | 34 | def test_single_server(self): 35 | servers = ["http://only-server.com"] 36 | lb = RandomLoadBalancer(servers) 37 | 38 | # Should always return the only server 39 | for _ in range(10): 40 | assert lb.choose() == "http://only-server.com" 41 | 42 | 43 | class TestRoundRobinLoadBalancer: 44 | def test_initialization_with_servers(self): 45 | servers = ["http://server1.com", "http://server2.com"] 46 | lb = RoundRobinLoadBalancer(servers) 47 | assert lb.servers == servers 48 | assert lb._current_index == 0 49 | 50 | def test_initialization_without_servers_raises_error(self): 51 | with pytest.raises(ValueError, match="At least one server must be provided"): 52 | RoundRobinLoadBalancer([]) 53 | 54 | def test_round_robin_selection(self): 55 | servers = ["http://server1.com", "http://server2.com", "http://server3.com"] 56 | lb = RoundRobinLoadBalancer(servers) 57 | 58 | # First round 59 | assert lb.choose() == "http://server1.com" 60 | assert lb.choose() == "http://server2.com" 61 | assert lb.choose() == "http://server3.com" 62 | 63 | # Second round should start over 64 | assert lb.choose() == "http://server1.com" 65 | assert lb.choose() == "http://server2.com" 66 | assert lb.choose() == "http://server3.com" 67 | 68 | def test_single_server(self): 69 | servers = ["http://only-server.com"] 70 | lb = RoundRobinLoadBalancer(servers) 71 | 72 | # Should always return the only server 73 | for _ in range(10): 74 | assert lb.choose() == "http://only-server.com" 75 | 76 | def test_two_servers_alternation(self): 77 | servers = ["http://server1.com", "http://server2.com"] 78 | lb = RoundRobinLoadBalancer(servers) 79 | 80 | # Should alternate between the two servers 81 | expected_sequence = ["http://server1.com", "http://server2.com"] * 5 82 | actual_sequence = [lb.choose() for _ in range(10)] 83 | 84 | assert actual_sequence == expected_sequence 85 | 86 | 87 | class TestFactoryFunctions: 88 | def test_create_random_load_balancer(self): 89 | servers = ["http://server1.com", "http://server2.com"] 90 | lb = create_random_load_balancer(servers) 91 | 92 | assert isinstance(lb, RandomLoadBalancer) 93 | assert lb.servers == servers 94 | 95 | def test_create_round_robin_load_balancer(self): 96 | servers = ["http://server1.com", "http://server2.com"] 97 | lb = create_round_robin_load_balancer(servers) 98 | 99 | assert isinstance(lb, RoundRobinLoadBalancer) 100 | assert lb.servers == servers 101 | 102 | def test_factory_functions_with_empty_list(self): 103 | with pytest.raises(ValueError, match="At least one server must be provided"): 104 | create_random_load_balancer([]) 105 | 106 | with pytest.raises(ValueError, match="At least one server must be provided"): 107 | create_round_robin_load_balancer([]) -------------------------------------------------------------------------------- /example_facade.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example demonstrating the Premier facade pattern usage. 3 | 4 | This example shows how to use the Premier class as a unified interface 5 | for all Premier functionality including caching, throttling, retry, and timing. 6 | """ 7 | 8 | import asyncio 9 | from premier import Premier 10 | 11 | 12 | async def main(): 13 | # Initialize Premier with default settings 14 | premier = Premier(keyspace="example") 15 | 16 | print("=== Premier Facade Example ===\n") 17 | 18 | # Example 1: Caching 19 | print("1. Caching Example:") 20 | 21 | @premier.cache_result(expire_s=60) 22 | async def expensive_calculation(n: int) -> int: 23 | print(f" Computing {n}^2 (expensive operation)") 24 | await asyncio.sleep(0.1) # Simulate expensive operation 25 | return n * n 26 | 27 | # First call - will compute 28 | result1 = await expensive_calculation(5) 29 | print(f" First call result: {result1}") 30 | 31 | # Second call - will use cache 32 | result2 = await expensive_calculation(5) 33 | print(f" Second call result: {result2} (from cache)") 34 | 35 | print() 36 | 37 | # Example 2: Throttling 38 | print("2. Throttling Example:") 39 | 40 | @premier.fixed_window(quota=3, duration=2) 41 | async def api_call(msg: str) -> str: 42 | print(f" API call: {msg}") 43 | return f"Response to: {msg}" 44 | 45 | # These should succeed 46 | for i in range(3): 47 | result = await api_call(f"Request {i+1}") 48 | print(f" {result}") 49 | 50 | print(" (Quota exhausted for this window)") 51 | print() 52 | 53 | # Example 3: Leaky Bucket 54 | print("3. Leaky Bucket Example:") 55 | 56 | @premier.leaky_bucket(bucket_size=2, quota=1, duration=1) 57 | async def rate_limited_task(task_id: int) -> str: 58 | print(f" Processing task {task_id}") 59 | return f"Task {task_id} completed" 60 | 61 | # First task processes immediately 62 | result = await rate_limited_task(1) 63 | print(f" {result}") 64 | 65 | print() 66 | 67 | # Example 4: Retry 68 | print("4. Retry Example:") 69 | 70 | attempt_count = 0 71 | 72 | @premier.retry(max_attempts=3, wait=0.1) 73 | async def flaky_service(data: str) -> str: 74 | nonlocal attempt_count 75 | attempt_count += 1 76 | print(f" Attempt {attempt_count} for data: {data}") 77 | 78 | if attempt_count < 3: 79 | raise ConnectionError("Service temporarily unavailable") 80 | 81 | return f"Successfully processed: {data}" 82 | 83 | try: 84 | result = await flaky_service("important data") 85 | print(f" {result}") 86 | except Exception as e: 87 | print(f" Failed: {e}") 88 | 89 | print() 90 | 91 | # Example 5: Timeout 92 | print("5. Timeout Example:") 93 | 94 | @premier.timeout(0.2) 95 | async def slow_operation() -> str: 96 | print(" Starting slow operation...") 97 | await asyncio.sleep(0.1) # This should complete in time 98 | return "Operation completed" 99 | 100 | try: 101 | result = await slow_operation() 102 | print(f" {result}") 103 | except asyncio.TimeoutError: 104 | print(" Operation timed out") 105 | 106 | print() 107 | 108 | # Example 6: Timing 109 | print("6. Timing Example:") 110 | 111 | @premier.timeit() 112 | def cpu_intensive_task(n: int) -> int: 113 | """Simulate CPU-intensive work.""" 114 | total = 0 115 | for i in range(n): 116 | total += i * i 117 | return total 118 | 119 | result = cpu_intensive_task(1000) 120 | print(f" Result: {result}") 121 | 122 | print() 123 | 124 | # Example 7: Combining multiple decorators 125 | print("7. Combined Decorators Example:") 126 | 127 | @premier.cache_result(expire_s=30) 128 | @premier.retry(max_attempts=2, wait=0.1) 129 | @premier.timeit() 130 | async def robust_api_call(endpoint: str) -> dict: 131 | print(f" Making robust API call to: {endpoint}") 132 | # Simulate some processing 133 | await asyncio.sleep(0.05) 134 | return {"endpoint": endpoint, "status": "success", "data": "example"} 135 | 136 | result = await robust_api_call("/users") 137 | print(f" API Result: {result}") 138 | 139 | print() 140 | 141 | # Cleanup 142 | print("8. Cleanup:") 143 | await premier.close() 144 | print(" Premier resources closed") 145 | 146 | print("\n=== Example Complete ===") 147 | 148 | 149 | if __name__ == "__main__": 150 | asyncio.run(main()) -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | This directory contains practical examples demonstrating Premier API Gateway features. 4 | 5 | ## Complete Example Application 6 | 7 | The `example/` directory contains a full-featured demonstration with: 8 | 9 | - **FastAPI Backend** (`app.py`) - Complete API with users, products, admin endpoints 10 | - **Premier Configuration** (`gateway.yaml`) - Production-ready gateway policies 11 | - **Dashboard Integration** (`main.py`) - Gateway with web UI 12 | - **Documentation** (`README.md`) - Detailed setup and testing guide 13 | 14 | ## Quick Start 15 | 16 | ```bash 17 | cd example 18 | uv run main.py 19 | ``` 20 | 21 | ![fastapi](/docs/images/fastapi_routes.png) 22 | 23 | Visit: http://localhost:8000/premier/dashboard 24 | 25 | ## What You'll Learn 26 | 27 | ### 1. Plugin Mode Integration 28 | ```python 29 | from premier.asgi import ASGIGateway, GatewayConfig 30 | from app import app 31 | 32 | config = GatewayConfig.from_file("gateway.yaml") 33 | gateway = ASGIGateway(config, app=app) 34 | ``` 35 | 36 | ### 2. YAML Configuration 37 | ```yaml 38 | premier: 39 | paths: 40 | - pattern: "/api/users*" 41 | features: 42 | cache: 43 | expire_s: 300 44 | rate_limit: 45 | quota: 100 46 | duration: 60 47 | ``` 48 | 49 | ### 3. Web Dashboard 50 | - Real-time monitoring 51 | - Configuration editing 52 | - Performance analytics 53 | - Cache management 54 | 55 | ## API Endpoints for Testing 56 | 57 | ### Caching Demo 58 | - `GET /api/users` - 5 minute cache 59 | - `GET /api/products` - 10 minute cache 60 | - `GET /api/search?q=alice` - 30 minute cache 61 | 62 | ### Rate Limiting Demo 63 | - `GET /api/admin/stats` - 10 requests/minute 64 | - `POST /api/bulk/process` - 5 requests/minute 65 | - `GET /api/users` - 100 requests/minute 66 | 67 | ### Resilience Demo 68 | - `GET /api/slow` - Tests timeouts (5 second limit) 69 | - `GET /api/unreliable` - Tests retry logic (60% failure rate) 70 | 71 | ### Monitoring Demo 72 | - All endpoints have performance thresholds 73 | - Dashboard shows real-time metrics 74 | - Request logs with timing data 75 | 76 | ## Testing Commands 77 | 78 | ### Cache Performance 79 | ```bash 80 | # Cache miss (slow) 81 | time curl http://localhost:8000/api/users 82 | 83 | # Cache hit (fast) 84 | time curl http://localhost:8000/api/users 85 | ``` 86 | 87 | ### Rate Limiting 88 | ```bash 89 | # Trigger rate limiting 90 | for i in {1..15}; do 91 | curl -w "%{http_code}\n" http://localhost:8000/api/admin/stats 92 | done 93 | ``` 94 | 95 | ### Timeout Handling 96 | ```bash 97 | # Some requests timeout after 5s 98 | curl -w "Time: %{time_total}s\n" http://localhost:8000/api/slow 99 | ``` 100 | 101 | ### Retry Logic 102 | ```bash 103 | # Automatic retries on failures 104 | curl -v http://localhost:8000/api/unreliable 105 | ``` 106 | 107 | ## Configuration Patterns 108 | 109 | ### High-Traffic Endpoints 110 | ```yaml 111 | - pattern: "/api/popular/*" 112 | features: 113 | cache: 114 | expire_s: 600 115 | rate_limit: 116 | quota: 1000 117 | algorithm: "token_bucket" 118 | ``` 119 | 120 | ### Admin/Sensitive Endpoints 121 | ```yaml 122 | - pattern: "/api/admin/*" 123 | features: 124 | rate_limit: 125 | quota: 10 126 | algorithm: "fixed_window" 127 | timeout: 128 | seconds: 30.0 129 | ``` 130 | 131 | ### Expensive Operations 132 | ```yaml 133 | - pattern: "/api/analytics/*" 134 | features: 135 | cache: 136 | expire_s: 1800 137 | rate_limit: 138 | quota: 5 139 | timeout: 140 | seconds: 60.0 141 | ``` 142 | 143 | ## Advanced Features 144 | 145 | ### Custom Cache Keys 146 | ```yaml 147 | cache: 148 | expire_s: 300 149 | key_template: "custom:{method}:{path}:{user_id}" 150 | ``` 151 | 152 | ### Multiple Rate Limit Algorithms 153 | - `sliding_window` - Smooth rate limiting 154 | - `token_bucket` - Burst handling 155 | - `fixed_window` - Simple quotas 156 | - `leaky_bucket` - Consistent flow 157 | 158 | ### Retry Strategies 159 | ```yaml 160 | retry: 161 | max_attempts: 5 162 | wait: 1.0 163 | backoff: "exponential" 164 | exceptions: ["ConnectionError", "TimeoutError"] 165 | ``` 166 | 167 | ## Production Deployment 168 | 169 | ### With Redis Backend 170 | ```python 171 | from premier.providers.redis import AsyncRedisCache 172 | from redis.asyncio import Redis 173 | 174 | redis_client = Redis.from_url("redis://localhost:6379") 175 | cache_provider = AsyncRedisCache(redis_client) 176 | 177 | gateway = ASGIGateway(config, app=app, cache_provider=cache_provider) 178 | ``` 179 | 180 | ### Multiple Workers 181 | ```bash 182 | uvicorn main:gateway --workers 4 --host 0.0.0.0 --port 8000 183 | ``` 184 | 185 | ### Docker Deployment 186 | ```dockerfile 187 | FROM python:3.10-slim 188 | COPY . /app 189 | WORKDIR /app 190 | RUN pip install -r requirements.txt 191 | CMD ["uvicorn", "main:gateway", "--host", "0.0.0.0", "--port", "8000"] 192 | ``` 193 | 194 | ## Next Steps 195 | 196 | 1. **Explore the Example** - Run the complete example and experiment with the dashboard 197 | 2. **Try Different Configs** - Modify `gateway.yaml` and see live changes 198 | 3. **Integration Testing** - Use your own FastAPI/Django app 199 | 4. **Production Setup** - Add Redis, monitoring, and scaling -------------------------------------------------------------------------------- /docs/examples.md.bak: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | This directory contains practical examples demonstrating Premier API Gateway features. 4 | 5 | ## Complete Example Application 6 | 7 | The `example/` directory contains a full-featured demonstration with: 8 | 9 | - **FastAPI Backend** (`app.py`) - Complete API with users, products, admin endpoints 10 | - **Premier Configuration** (`gateway.yaml`) - Production-ready gateway policies 11 | - **Dashboard Integration** (`main.py`) - Gateway with web UI 12 | - **Documentation** (`README.md`) - Detailed setup and testing guide 13 | 14 | ## Quick Start 15 | 16 | ```bash 17 | cd example 18 | uv run main.py 19 | ``` 20 | 21 | ![fastapi](/docs/images/fastapi_routes.png) 22 | 23 | Visit: http://localhost:8000/premier/dashboard 24 | 25 | ## What You'll Learn 26 | 27 | ### 1. Plugin Mode Integration 28 | ```python 29 | from premier.asgi import ASGIGateway, GatewayConfig 30 | from app import app 31 | 32 | config = GatewayConfig.from_file("gateway.yaml") 33 | gateway = ASGIGateway(config, app=app) 34 | ``` 35 | 36 | ### 2. YAML Configuration 37 | ```yaml 38 | premier: 39 | paths: 40 | - pattern: "/api/users*" 41 | features: 42 | cache: 43 | expire_s: 300 44 | rate_limit: 45 | quota: 100 46 | duration: 60 47 | ``` 48 | 49 | ### 3. Web Dashboard 50 | - Real-time monitoring 51 | - Configuration editing 52 | - Performance analytics 53 | - Cache management 54 | 55 | ## API Endpoints for Testing 56 | 57 | ### Caching Demo 58 | - `GET /api/users` - 5 minute cache 59 | - `GET /api/products` - 10 minute cache 60 | - `GET /api/search?q=alice` - 30 minute cache 61 | 62 | ### Rate Limiting Demo 63 | - `GET /api/admin/stats` - 10 requests/minute 64 | - `POST /api/bulk/process` - 5 requests/minute 65 | - `GET /api/users` - 100 requests/minute 66 | 67 | ### Resilience Demo 68 | - `GET /api/slow` - Tests timeouts (5 second limit) 69 | - `GET /api/unreliable` - Tests retry logic (60% failure rate) 70 | 71 | ### Monitoring Demo 72 | - All endpoints have performance thresholds 73 | - Dashboard shows real-time metrics 74 | - Request logs with timing data 75 | 76 | ## Testing Commands 77 | 78 | ### Cache Performance 79 | ```bash 80 | # Cache miss (slow) 81 | time curl http://localhost:8000/api/users 82 | 83 | # Cache hit (fast) 84 | time curl http://localhost:8000/api/users 85 | ``` 86 | 87 | ### Rate Limiting 88 | ```bash 89 | # Trigger rate limiting 90 | for i in {1..15}; do 91 | curl -w "%{http_code}\n" http://localhost:8000/api/admin/stats 92 | done 93 | ``` 94 | 95 | ### Timeout Handling 96 | ```bash 97 | # Some requests timeout after 5s 98 | curl -w "Time: %{time_total}s\n" http://localhost:8000/api/slow 99 | ``` 100 | 101 | ### Retry Logic 102 | ```bash 103 | # Automatic retries on failures 104 | curl -v http://localhost:8000/api/unreliable 105 | ``` 106 | 107 | ## Configuration Patterns 108 | 109 | ### High-Traffic Endpoints 110 | ```yaml 111 | - pattern: "/api/popular/*" 112 | features: 113 | cache: 114 | expire_s: 600 115 | rate_limit: 116 | quota: 1000 117 | algorithm: "token_bucket" 118 | ``` 119 | 120 | ### Admin/Sensitive Endpoints 121 | ```yaml 122 | - pattern: "/api/admin/*" 123 | features: 124 | rate_limit: 125 | quota: 10 126 | algorithm: "fixed_window" 127 | timeout: 128 | seconds: 30.0 129 | ``` 130 | 131 | ### Expensive Operations 132 | ```yaml 133 | - pattern: "/api/analytics/*" 134 | features: 135 | cache: 136 | expire_s: 1800 137 | rate_limit: 138 | quota: 5 139 | timeout: 140 | seconds: 60.0 141 | ``` 142 | 143 | ## Advanced Features 144 | 145 | ### Custom Cache Keys 146 | ```yaml 147 | cache: 148 | expire_s: 300 149 | key_template: "custom:{method}:{path}:{user_id}" 150 | ``` 151 | 152 | ### Multiple Rate Limit Algorithms 153 | - `sliding_window` - Smooth rate limiting 154 | - `token_bucket` - Burst handling 155 | - `fixed_window` - Simple quotas 156 | - `leaky_bucket` - Consistent flow 157 | 158 | ### Retry Strategies 159 | ```yaml 160 | retry: 161 | max_attempts: 5 162 | wait: 1.0 163 | backoff: "exponential" 164 | exceptions: ["ConnectionError", "TimeoutError"] 165 | ``` 166 | 167 | ## Production Deployment 168 | 169 | ### With Redis Backend 170 | ```python 171 | from premier.providers.redis import AsyncRedisCache 172 | from redis.asyncio import Redis 173 | 174 | redis_client = Redis.from_url("redis://localhost:6379") 175 | cache_provider = AsyncRedisCache(redis_client) 176 | 177 | gateway = ASGIGateway(config, app=app, cache_provider=cache_provider) 178 | ``` 179 | 180 | ### Multiple Workers 181 | ```bash 182 | uvicorn main:gateway --workers 4 --host 0.0.0.0 --port 8000 183 | ``` 184 | 185 | ### Docker Deployment 186 | ```dockerfile 187 | FROM python:3.10-slim 188 | COPY . /app 189 | WORKDIR /app 190 | RUN pip install -r requirements.txt 191 | CMD ["uvicorn", "main:gateway", "--host", "0.0.0.0", "--port", "8000"] 192 | ``` 193 | 194 | ## Next Steps 195 | 196 | 1. **Explore the Example** - Run the complete example and experiment with the dashboard 197 | 2. **Try Different Configs** - Modify `gateway.yaml` and see live changes 198 | 3. **Integration Testing** - Use your own FastAPI/Django app 199 | 4. **Production Setup** - Add Redis, monitoring, and scaling -------------------------------------------------------------------------------- /docs/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quick Start Guide 2 | 3 | This guide will help you get started with Premier in just a few minutes. 4 | 5 | ## Choose Your Integration Mode 6 | 7 | Premier supports three different integration modes: 8 | 9 | 1. **[Plugin Mode](#plugin-mode)** - Recommended for wrapping existing ASGI applications 10 | 2. **[Standalone Mode](#standalone-mode)** - For creating a dedicated API gateway 11 | 3. **[Decorator Mode](#decorator-mode)** - For adding features to individual functions 12 | 13 | ## Plugin Mode 14 | 15 | Perfect for adding gateway features to existing FastAPI, Django, or other ASGI applications. 16 | 17 | ### Step 1: Create Your ASGI Application 18 | 19 | ```python 20 | # app.py 21 | from fastapi import FastAPI 22 | 23 | app = FastAPI() 24 | 25 | @app.get("/api/users/{user_id}") 26 | async def get_user(user_id: int): 27 | return {"id": user_id, "name": f"User {user_id}"} 28 | 29 | @app.get("/api/posts") 30 | async def get_posts(): 31 | return [{"id": 1, "title": "Hello World"}] 32 | ``` 33 | 34 | ### Step 2: Create Gateway Configuration 35 | 36 | ```yaml 37 | # gateway.yaml 38 | premier: 39 | keyspace: "my-api" 40 | 41 | paths: 42 | - pattern: "/api/users/*" 43 | features: 44 | cache: 45 | expire_s: 300 46 | rate_limit: 47 | quota: 100 48 | duration: 60 49 | timeout: 50 | seconds: 5.0 51 | 52 | - pattern: "/api/posts" 53 | features: 54 | cache: 55 | expire_s: 600 56 | rate_limit: 57 | quota: 200 58 | duration: 60 59 | ``` 60 | 61 | ### Step 3: Wrap Your Application 62 | 63 | ```python 64 | # gateway.py 65 | from premier.asgi import ASGIGateway, GatewayConfig 66 | from .app import app 67 | 68 | # Load configuration and wrap your app 69 | config = GatewayConfig.from_file("gateway.yaml") 70 | gateway_app = ASGIGateway(config=config, app=app) 71 | ``` 72 | 73 | ### Step 4: Run Your Gateway 74 | 75 | ```bash 76 | uvicorn gateway:gateway_app --reload 77 | ``` 78 | 79 | That's it! Your application now has caching, rate limiting, and timeout protection. 80 | 81 | ## Standalone Mode 82 | 83 | Create a dedicated API gateway that forwards requests to backend services. 84 | 85 | ### Step 1: Create Gateway Configuration 86 | 87 | ```yaml 88 | # gateway.yaml 89 | premier: 90 | keyspace: "gateway" 91 | servers: 92 | - "http://backend1:8000" 93 | - "http://backend2:8000" 94 | 95 | paths: 96 | - pattern: "/api/*" 97 | features: 98 | cache: 99 | expire_s: 300 100 | rate_limit: 101 | quota: 1000 102 | duration: 60 103 | timeout: 104 | seconds: 10.0 105 | retry: 106 | max_attempts: 3 107 | wait: 1.0 108 | ``` 109 | 110 | ### Step 2: Create Gateway Service 111 | 112 | ```python 113 | # gateway.py 114 | from premier.asgi import ASGIGateway, GatewayConfig 115 | 116 | config = GatewayConfig.from_file("gateway.yaml") 117 | app = ASGIGateway(config) 118 | ``` 119 | 120 | ### Step 3: Run Your Gateway 121 | 122 | ```bash 123 | uvicorn gateway:app 124 | ``` 125 | 126 | The gateway will load balance requests between your backend servers with full gateway features. 127 | 128 | ## Decorator Mode 129 | 130 | Add Premier features directly to individual functions. 131 | 132 | ### Step 1: Use Premier Decorators 133 | 134 | ```python 135 | from premier.features.retry import retry 136 | from premier.features.timer import timeit, timeout 137 | from premier.features.cache import cache 138 | 139 | @cache(expire_s=300) 140 | @retry(max_attempts=3, wait=1.0) 141 | @timeout(seconds=5.0) 142 | @timeit(log_threshold=0.1) 143 | async def fetch_user_data(user_id: int): 144 | # Your function with retry, timeout, caching, and timing 145 | async with httpx.AsyncClient() as client: 146 | response = await client.get(f"https://api.example.com/users/{user_id}") 147 | return response.json() 148 | ``` 149 | 150 | ### Step 2: Use Your Functions 151 | 152 | ```python 153 | # Automatic retry on failure, caching, timeout protection 154 | user_data = await fetch_user_data(123) 155 | ``` 156 | 157 | ## Accessing the Web Dashboard 158 | 159 | Premier includes a built-in web dashboard for monitoring and configuration. 160 | 161 | Add this to your configuration: 162 | 163 | ```yaml 164 | premier: 165 | dashboard: 166 | enabled: true 167 | path: "/premier/dashboard" 168 | ``` 169 | 170 | Then visit `http://localhost:8000/premier/dashboard` to see: 171 | 172 | - Real-time request metrics 173 | - Cache statistics 174 | - Rate limiting status 175 | - Configuration management 176 | - Performance analytics 177 | 178 | ## Production Configuration 179 | 180 | For production deployments, consider using Redis for distributed caching: 181 | 182 | ```python 183 | from premier.asgi import ASGIGateway, GatewayConfig 184 | from premier.providers.redis import AsyncRedisCache 185 | from redis.asyncio import Redis 186 | 187 | # Redis backend for distributed caching 188 | redis_client = Redis.from_url("redis://localhost:6379") 189 | cache_provider = AsyncRedisCache(redis_client) 190 | 191 | # Load configuration 192 | config = GatewayConfig.from_file("production.yaml") 193 | 194 | # Create production gateway 195 | app = ASGIGateway(config, app=your_app, cache_provider=cache_provider) 196 | ``` 197 | 198 | ## Next Steps 199 | 200 | - **[Configuration Guide](configuration.md)** - Learn about all configuration options 201 | - **[Web Dashboard](web-gui.md)** - Explore the monitoring interface 202 | - **[Examples](examples.md)** - See complete working examples -------------------------------------------------------------------------------- /premier/features/throttler/throttler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import inspect 3 | from functools import wraps 4 | from typing import Awaitable, Callable 5 | 6 | from premier.providers import AsyncInMemoryCache 7 | from premier.features.throttler.errors import QuotaExceedsError, UninitializedHandlerError 8 | from premier.features.throttler.handler import AsyncDefaultHandler 9 | from premier.features.throttler.interface import ( 10 | AsyncThrottleHandler, 11 | KeyMaker, 12 | P, 13 | R, 14 | ThrottleAlgo, 15 | _make_key, 16 | ) 17 | 18 | 19 | class Throttler: 20 | """ 21 | Async-only throttler for rate limiting functions 22 | """ 23 | 24 | _aiohandler: AsyncThrottleHandler 25 | _keyspace: str 26 | _algo: ThrottleAlgo 27 | 28 | def __init__( 29 | self, 30 | handler: AsyncThrottleHandler | None = None, 31 | algo: ThrottleAlgo = ThrottleAlgo.FIXED_WINDOW, 32 | keyspace: str = "premier:throttler", 33 | ): 34 | self._aiohandler = handler or AsyncDefaultHandler(AsyncInMemoryCache()) 35 | self._algo = algo 36 | self._keyspace = keyspace 37 | 38 | @property 39 | def default_algo(self): 40 | return self._algo 41 | 42 | async def clear(self, keyspace: str | None = None): 43 | if not self._aiohandler: 44 | return 45 | if keyspace is None: 46 | keyspace = self._keyspace 47 | await self._aiohandler.clear(keyspace) 48 | 49 | def throttle( 50 | self, 51 | throttle_algo: ThrottleAlgo, 52 | quota: int, 53 | duration: int, 54 | keymaker: KeyMaker | None = None, 55 | bucket_size: int = -1, 56 | ) -> Callable[[Callable[P, R]], Callable[P, Awaitable[R | None]]]: 57 | 58 | def wrapper(func: Callable[P, R]) -> Callable[P, Awaitable[R | None]]: 59 | @wraps(func) 60 | async def ainner(*args: P.args, **kwargs: P.kwargs) -> R | None: 61 | if not self._aiohandler: 62 | raise UninitializedHandlerError("Async handler not configured") 63 | key = _make_key( 64 | func, 65 | algo=throttle_algo, 66 | keyspace=self._keyspace, 67 | args=args, 68 | kwargs=kwargs, 69 | keymaker=keymaker, 70 | ) 71 | if throttle_algo is ThrottleAlgo.LEAKY_BUCKET: 72 | countdown = await self._aiohandler.leaky_bucket( 73 | key, bucket_size=bucket_size, quota=quota, duration=duration 74 | ) 75 | if countdown != -1: 76 | # For leaky bucket, we sleep for the delay instead of raising error 77 | await asyncio.sleep(countdown) 78 | 79 | # Execute the function after delay 80 | if asyncio.iscoroutinefunction(func): 81 | return await func(*args, **kwargs) 82 | else: 83 | return func(*args, **kwargs) 84 | countdown = await self._aiohandler.dispatch(throttle_algo)( 85 | key, quota=quota, duration=duration 86 | ) 87 | if countdown != -1: 88 | raise QuotaExceedsError(quota, duration, countdown) 89 | 90 | # Handle both sync and async functions 91 | if inspect.iscoroutinefunction(func): 92 | return await func(*args, **kwargs) 93 | else: 94 | return func(*args, **kwargs) 95 | 96 | return ainner 97 | 98 | return wrapper 99 | 100 | def fixed_window(self, quota: int, duration: int, keymaker: KeyMaker | None = None): 101 | return self.throttle( 102 | quota=quota, 103 | duration=duration, 104 | keymaker=keymaker, 105 | throttle_algo=ThrottleAlgo.FIXED_WINDOW, 106 | ) 107 | 108 | def sliding_window( 109 | self, quota: int, duration: int, keymaker: KeyMaker | None = None 110 | ): 111 | return self.throttle( 112 | quota=quota, 113 | duration=duration, 114 | keymaker=keymaker, 115 | throttle_algo=ThrottleAlgo.SLIDING_WINDOW, 116 | ) 117 | 118 | def token_bucket(self, quota: int, duration: int, keymaker: KeyMaker | None = None): 119 | return self.throttle( 120 | quota=quota, 121 | duration=duration, 122 | keymaker=keymaker, 123 | throttle_algo=ThrottleAlgo.TOKEN_BUCKET, 124 | ) 125 | 126 | def leaky_bucket( 127 | self, 128 | quota: int, 129 | bucket_size: int, 130 | duration: int, 131 | keymaker: KeyMaker | None = None, 132 | ): 133 | return self.throttle( 134 | bucket_size=bucket_size, 135 | quota=quota, 136 | duration=duration, 137 | keymaker=keymaker, 138 | throttle_algo=ThrottleAlgo.LEAKY_BUCKET, 139 | ) 140 | 141 | async def get_countdown( 142 | self, 143 | throttle_algo: ThrottleAlgo, 144 | quota: int, 145 | duration: int, 146 | key: str | None = None, 147 | ): 148 | key = key or f"{self._keyspace}:" 149 | if not self._aiohandler: 150 | raise UninitializedHandlerError("Async handler not configured") 151 | countdown = await self._aiohandler.dispatch(throttle_algo)( 152 | key, quota=quota, duration=duration 153 | ) 154 | if countdown == -1: # func is ready to be executed 155 | return 156 | raise QuotaExceedsError(quota, duration, countdown) 157 | -------------------------------------------------------------------------------- /premier/features/throttler/interface.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from dataclasses import dataclass 3 | from enum import Enum, auto 4 | from types import FunctionType, MethodType 5 | from typing import ( 6 | Any, 7 | Awaitable, 8 | Callable, 9 | Generic, 10 | Iterable, 11 | Literal, 12 | ParamSpec, 13 | Protocol, 14 | Sequence, 15 | TypeVar, 16 | ) 17 | 18 | KeyMaker = Callable[..., str] 19 | CountDown = Literal[-1] | float 20 | 21 | 22 | KeysT = TypeVar("KeysT", bound=Sequence[bytes | str | memoryview], contravariant=True) 23 | ArgsT = TypeVar( 24 | "ArgsT", 25 | bound=Iterable[str | int | float | bytes | memoryview], 26 | contravariant=True, 27 | ) 28 | ResultT = TypeVar("ResultT", bound=Any, covariant=True) 29 | AsyncTaskScheduler = Callable[..., Awaitable[None]] 30 | 31 | 32 | T = TypeVar("T") 33 | P = ParamSpec("P") 34 | R = TypeVar("R", covariant=True) 35 | def _func_keymaker(func: Callable[..., Any], algo: "ThrottleAlgo", keyspace: str): 36 | if isinstance(func, MethodType): 37 | # It's a method, get its class name and method name 38 | class_name = func.__self__.__class__.__name__ 39 | func_name = func.__name__ 40 | fid = f"{class_name}:{func_name}" 41 | elif isinstance(func, FunctionType): 42 | # It's a standalone function 43 | fid = func.__name__ 44 | else: 45 | try: 46 | fid = func.__name__ 47 | except AttributeError: 48 | fid = "" 49 | 50 | return f"{keyspace}:{algo.value}:{func.__module__}:{fid}" 51 | 52 | 53 | def _make_key( 54 | func: Callable[..., Any], 55 | algo: "ThrottleAlgo", 56 | keyspace: str, 57 | keymaker: KeyMaker | None, 58 | args: tuple[object, ...], 59 | kwargs: dict[Any, Any], 60 | ) -> str: 61 | key = _func_keymaker(func, algo, keyspace) 62 | if not keymaker: 63 | return key 64 | return f"{key}:{keymaker(*args, **kwargs)}" 65 | 66 | 67 | 68 | 69 | class SyncFunc(Protocol[P, R]): 70 | __name__: str 71 | 72 | def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: ... 73 | 74 | 75 | class AsyncFunc(Protocol[P, R]): 76 | __name__: str 77 | 78 | async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: ... 79 | 80 | 81 | class ScriptFunc(Protocol, Generic[KeysT, ArgsT, ResultT]): 82 | def __call__(self, keys: KeysT, args: ArgsT) -> ResultT: 83 | raise NotImplementedError 84 | 85 | 86 | @dataclass(frozen=True, slots=True, kw_only=True) 87 | class ThrottleInfo: 88 | func: Callable[..., Any] 89 | keyspace: str 90 | algo: "ThrottleAlgo" 91 | 92 | @property 93 | def funckey(self): 94 | return _func_keymaker(self.func, self.algo, self.keyspace) 95 | 96 | def make_key( 97 | self, 98 | keymaker: KeyMaker | None, 99 | args: tuple[object, ...], 100 | kwargs: dict[Any, Any], 101 | ) -> str: 102 | key = self.funckey 103 | if not keymaker: 104 | return key 105 | 106 | return f"{key}:{keymaker(*args, **kwargs)}" 107 | 108 | 109 | @dataclass(frozen=True, slots=True, kw_only=True) 110 | class LBThrottleInfo(ThrottleInfo): 111 | bucket_size: int 112 | 113 | 114 | @dataclass(kw_only=True) 115 | class Duration: 116 | seconds: int 117 | minutes: int 118 | hours: int 119 | days: int 120 | 121 | @classmethod 122 | def from_seconds(cls, seconds: int): 123 | total = seconds 124 | d = h = m = 0 125 | 126 | while total >= 86400: 127 | d += 1 128 | total -= 86400 129 | 130 | while total >= 3600: 131 | h += 1 132 | total -= 3600 133 | 134 | while total >= 60: 135 | m += 1 136 | total -= 60 137 | return cls(seconds=total, minutes=m, hours=h, days=d) 138 | 139 | def as_seconds(self): 140 | total = ( 141 | (self.days * 86400) 142 | + (self.hours * 3600) 143 | + (self.minutes * 60) 144 | + self.seconds 145 | ) 146 | return total 147 | 148 | 149 | class AlgoTypeEnum(Enum): 150 | @staticmethod 151 | def _generate_next_value_(name: str, _start: int, _count: int, _last_values: list[Any]): 152 | return name.lower() # type: ignore 153 | 154 | 155 | class ThrottleAlgo(str, AlgoTypeEnum): 156 | TOKEN_BUCKET = auto() 157 | LEAKY_BUCKET = auto() 158 | FIXED_WINDOW = auto() 159 | SLIDING_WINDOW = auto() 160 | 161 | 162 | 163 | 164 | class AsyncThrottleHandler(ABC): 165 | 166 | @abstractmethod 167 | async def fixed_window(self, key: str, quota: int, duration: int) -> CountDown: 168 | pass 169 | 170 | @abstractmethod 171 | async def sliding_window(self, key: str, quota: int, duration: int) -> CountDown: 172 | pass 173 | 174 | @abstractmethod 175 | async def token_bucket(self, key: str, quota: int, duration: int) -> CountDown: 176 | pass 177 | 178 | @abstractmethod 179 | async def leaky_bucket( 180 | self, key: str, bucket_size: int, quota: int, duration: int 181 | ) -> CountDown: ... 182 | 183 | @abstractmethod 184 | async def clear(self, keyspace: str = "") -> None: 185 | pass 186 | 187 | @abstractmethod 188 | async def close(self) -> None: 189 | pass 190 | 191 | def dispatch(self, algo: ThrottleAlgo): 192 | "does not handle the leaky bucket case" 193 | match algo: 194 | case ThrottleAlgo.FIXED_WINDOW: 195 | return self.fixed_window 196 | case ThrottleAlgo.SLIDING_WINDOW: 197 | return self.sliding_window 198 | case ThrottleAlgo.TOKEN_BUCKET: 199 | return self.token_bucket 200 | case _: 201 | raise NotImplementedError 202 | -------------------------------------------------------------------------------- /example_logging.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example demonstrating Premier with logging functionality. 3 | 4 | This example shows how to use the Premier class with proper logging 5 | for retry attempts, timeouts, and timing measurements. 6 | """ 7 | 8 | import asyncio 9 | import logging 10 | from premier import Premier, ILogger 11 | 12 | 13 | # Set up logging 14 | logging.basicConfig( 15 | level=logging.INFO, 16 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 17 | ) 18 | 19 | 20 | class StandardLogger: 21 | """Standard logger that implements ILogger interface.""" 22 | 23 | def __init__(self, name: str): 24 | self.logger = logging.getLogger(name) 25 | 26 | def info(self, msg: str): 27 | self.logger.info(msg) 28 | 29 | def exception(self, msg: str): 30 | self.logger.exception(msg) 31 | 32 | 33 | async def main(): 34 | # Initialize Premier with logging 35 | premier = Premier(keyspace="logging_example") 36 | logger = StandardLogger("premier_example") 37 | 38 | print("=== Premier Logging Example ===\n") 39 | 40 | # Example 1: Retry with logging 41 | print("1. Retry with Logging:") 42 | 43 | attempt_count = 0 44 | 45 | @premier.retry(max_attempts=3, wait=0.5, logger=logger) 46 | async def flaky_service(data: str) -> str: 47 | nonlocal attempt_count 48 | attempt_count += 1 49 | print(f" Executing attempt {attempt_count}") 50 | 51 | if attempt_count < 3: 52 | raise ConnectionError("Service temporarily unavailable") 53 | 54 | return f"Successfully processed: {data}" 55 | 56 | try: 57 | result = await flaky_service("important data") 58 | print(f" Final result: {result}") 59 | except Exception as e: 60 | print(f" Failed: {e}") 61 | 62 | print() 63 | 64 | # Example 2: Timeout with logging 65 | print("2. Timeout with Logging:") 66 | 67 | @premier.timeout(0.2, logger=logger) 68 | async def slow_operation() -> str: 69 | print(" Starting operation that might timeout...") 70 | await asyncio.sleep(0.3) # This will timeout 71 | return "Operation completed" 72 | 73 | try: 74 | result = await slow_operation() 75 | print(f" Result: {result}") 76 | except asyncio.TimeoutError: 77 | print(" Operation timed out (logged to logger)") 78 | 79 | print() 80 | 81 | # Example 3: Timing with logging 82 | print("3. Timing with Logging:") 83 | 84 | @premier.timeit(logger=logger, log_threshold=0.01, with_args=True) 85 | async def timed_operation(n: int, operation: str) -> int: 86 | """Simulate some work with timing.""" 87 | print(f" Performing {operation} on {n}") 88 | await asyncio.sleep(0.05) # Simulate work 89 | return n * 2 90 | 91 | result = await timed_operation(42, "calculation") 92 | print(f" Result: {result}") 93 | 94 | print() 95 | 96 | # Example 4: Custom logger with different log levels 97 | print("4. Custom Logger Levels:") 98 | 99 | class VerboseLogger: 100 | """More verbose logger with different log levels.""" 101 | 102 | def __init__(self): 103 | self.logger = logging.getLogger("verbose") 104 | 105 | def info(self, msg: str): 106 | self.logger.info(f"[INFO] {msg}") 107 | 108 | def exception(self, msg: str): 109 | self.logger.error(f"[ERROR] {msg}") 110 | 111 | verbose_logger = VerboseLogger() 112 | 113 | @premier.retry(max_attempts=2, wait=0.2, logger=verbose_logger) 114 | async def quick_fail(): 115 | print(" Trying quick operation...") 116 | raise ValueError("Quick failure") 117 | 118 | try: 119 | await quick_fail() 120 | except ValueError: 121 | print(" Quick operation failed (check logs)") 122 | 123 | print() 124 | 125 | # Example 5: Combined logging with caching and throttling 126 | print("5. Combined Operations with Logging:") 127 | 128 | @premier.cache_result(expire_s=60) 129 | @premier.retry(max_attempts=2, wait=0.1, logger=logger) 130 | @premier.timeit(logger=logger, with_args=True) 131 | async def robust_api_call(endpoint: str) -> dict: 132 | print(f" Making API call to: {endpoint}") 133 | 134 | # Simulate occasional failure 135 | import random 136 | if random.random() < 0.3: 137 | raise ConnectionError("Network error") 138 | 139 | await asyncio.sleep(0.02) # Simulate API call 140 | return {"endpoint": endpoint, "status": "success", "timestamp": asyncio.get_event_loop().time()} 141 | 142 | try: 143 | result = await robust_api_call("/users") 144 | print(f" API Result: {result}") 145 | 146 | # Second call should use cache 147 | result2 = await robust_api_call("/users") 148 | print(f" Cached Result: {result2}") 149 | 150 | except Exception as e: 151 | print(f" API call failed: {e}") 152 | 153 | print() 154 | 155 | # Example 6: Silent operation (no logger) 156 | print("6. Silent Operation (no logging):") 157 | 158 | @premier.retry(max_attempts=2, wait=0.1) # No logger 159 | async def silent_operation(): 160 | print(" Silent retry operation") 161 | raise RuntimeError("Silent failure") 162 | 163 | try: 164 | await silent_operation() 165 | except RuntimeError: 166 | print(" Silent operation failed (no retry logs)") 167 | 168 | print() 169 | 170 | # Cleanup 171 | print("7. Cleanup:") 172 | await premier.close() 173 | print(" Premier resources closed") 174 | 175 | print("\n=== Logging Example Complete ===") 176 | 177 | 178 | if __name__ == "__main__": 179 | asyncio.run(main()) -------------------------------------------------------------------------------- /premier/features/throttler/handler.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from premier._logs import logger as logger 4 | from premier.providers import AsyncCacheProvider 5 | from premier.features.throttler.errors import QuotaExceedsError 6 | from premier.features.throttler.interface import ( 7 | AsyncThrottleHandler, 8 | CountDown, 9 | ) 10 | 11 | 12 | class _Timer: 13 | def __init__(self, timer_func=time.perf_counter): 14 | self._timer_func = timer_func 15 | 16 | def __call__(self) -> float: 17 | return self._timer_func() 18 | 19 | 20 | class AsyncDefaultHandler(AsyncThrottleHandler): 21 | def __init__(self, cache: AsyncCacheProvider, timer: _Timer | None = None): 22 | self._cache = cache 23 | self._timer = timer or _Timer() 24 | 25 | async def fixed_window(self, key: str, quota: int, duration: int) -> CountDown: 26 | cached_value = await self._cache.get(key) 27 | now = self._timer() 28 | 29 | if cached_value is None: 30 | if quota >= 1: 31 | await self._cache.set(key, (now + duration, 1)) 32 | return -1 33 | else: 34 | await self._cache.set(key, (now + duration, 0)) 35 | return duration 36 | 37 | time_val, cnt = cached_value 38 | 39 | if now > time_val: 40 | if quota >= 1: 41 | await self._cache.set(key, (now + duration, 1)) 42 | return -1 43 | else: 44 | await self._cache.set(key, (now + duration, 0)) 45 | return duration 46 | 47 | if cnt >= quota: 48 | return time_val - now 49 | 50 | await self._cache.set(key, (time_val, cnt + 1)) 51 | return -1 52 | 53 | async def sliding_window(self, key: str, quota: int, duration: int) -> CountDown: 54 | now = self._timer() 55 | cached_value = await self._cache.get(key) 56 | time_val, cnt = cached_value or (now, 0) 57 | 58 | elapsed = now - time_val 59 | complete_durations = int(elapsed // duration) 60 | 61 | if complete_durations >= 1: 62 | await self._cache.set(key, (now, 1)) 63 | return -1 64 | 65 | window_progress = elapsed % duration 66 | sliding_window_start = now - window_progress 67 | adjusted_cnt = cnt - int((elapsed // duration) * quota) 68 | cnt = max(0, adjusted_cnt) 69 | 70 | if cnt >= quota: 71 | remains = (duration - window_progress) + ( 72 | (cnt - quota + 1) / quota 73 | ) * duration 74 | return remains 75 | 76 | await self._cache.set(key, (sliding_window_start, cnt + 1)) 77 | return -1 78 | 79 | async def token_bucket(self, key: str, quota: int, duration: int) -> CountDown: 80 | now = self._timer() 81 | cached_value = await self._cache.get(key) 82 | last_token_time, tokens = cached_value or (now, quota) 83 | 84 | refill_rate = quota / duration 85 | elapsed = now - last_token_time 86 | new_tokens = min(quota, tokens + int(elapsed * refill_rate)) 87 | 88 | if new_tokens < 1: 89 | return (1 - new_tokens) / refill_rate 90 | 91 | await self._cache.set(key, (now, new_tokens - 1)) 92 | return -1 93 | 94 | async def leaky_bucket( 95 | self, key: str, bucket_size: int, quota: int, duration: int 96 | ) -> CountDown: 97 | """Simplified leaky bucket implementation without task queue. 98 | 99 | Returns -1 if request can proceed immediately, or delay in seconds if bucket is full. 100 | Raises BucketFullError if the bucket capacity is exceeded. 101 | """ 102 | now = self._timer() 103 | bucket_key = f"{key}:bucket" 104 | 105 | # Get current bucket state: (last_leak_time, current_count) 106 | cached_value = await self._cache.get(bucket_key) 107 | last_leak_time, current_count = cached_value or (now, 0) 108 | 109 | # Calculate leak rate (requests per second) 110 | leak_rate = quota / duration 111 | elapsed = now - last_leak_time 112 | 113 | # Calculate how many tokens have leaked out 114 | leaked_tokens = int(elapsed * leak_rate) 115 | current_count = max(0, current_count - leaked_tokens) 116 | 117 | # Check if bucket is full 118 | if current_count >= bucket_size: 119 | raise QuotaExceedsError(bucket_size, duration, duration / leak_rate) 120 | 121 | # Calculate delay until next token can be processed 122 | if current_count == 0: 123 | # Bucket is empty, can process immediately 124 | await self._cache.set(bucket_key, (now, 1)) 125 | return -1 126 | 127 | # Add current request to bucket and calculate delay 128 | new_count = current_count + 1 129 | await self._cache.set(bucket_key, (now, new_count)) 130 | 131 | # Delay is based on position in queue 132 | delay = (new_count - 1) / leak_rate 133 | return delay 134 | 135 | async def clear(self, keyspace: str = ""): 136 | await self._cache.clear(keyspace) 137 | 138 | async def close(self) -> None: 139 | await self._cache.close() 140 | 141 | 142 | try: 143 | from redis.asyncio.client import Redis as AIORedis 144 | 145 | from premier.providers.redis import AsyncRedisCache 146 | 147 | 148 | class AsyncRedisHandler(AsyncDefaultHandler): 149 | def __init__(self, cache: AsyncRedisCache): 150 | super().__init__(cache) 151 | self._cache = cache 152 | 153 | @classmethod 154 | def from_url(cls, url: str) -> "AsyncRedisHandler": 155 | redis = AIORedis.from_url(url) 156 | cache = AsyncRedisCache(redis) 157 | return cls(cache) 158 | 159 | 160 | async def close(self) -> None: 161 | await super().close() 162 | 163 | except ImportError: 164 | pass 165 | -------------------------------------------------------------------------------- /example/fastapi/app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | import time 4 | from typing import List, Dict, Any 5 | 6 | from fastapi import FastAPI, HTTPException 7 | from fastapi.responses import JSONResponse 8 | 9 | # Create FastAPI app 10 | app = FastAPI( 11 | title="Example API", 12 | description="A sample API to demonstrate Premier Gateway features", 13 | version="1.0.0" 14 | ) 15 | 16 | # Mock data store 17 | users_db = { 18 | 1: {"id": 1, "name": "Alice", "email": "alice@example.com", "role": "admin"}, 19 | 2: {"id": 2, "name": "Bob", "email": "bob@example.com", "role": "user"}, 20 | 3: {"id": 3, "name": "Charlie", "email": "charlie@example.com", "role": "user"}, 21 | } 22 | 23 | products_db = { 24 | 1: {"id": 1, "name": "Laptop", "price": 999.99, "category": "electronics"}, 25 | 2: {"id": 2, "name": "Book", "price": 29.99, "category": "books"}, 26 | 3: {"id": 3, "name": "Coffee", "price": 4.99, "category": "beverages"}, 27 | } 28 | 29 | # Health check endpoint 30 | @app.get("/health") 31 | async def health_check(): 32 | """Quick health check endpoint""" 33 | return {"status": "healthy", "timestamp": time.time()} 34 | 35 | # User endpoints 36 | @app.get("/api/users") 37 | async def get_users(): 38 | """Get all users - demonstrates caching""" 39 | # Simulate database delay 40 | await asyncio.sleep(0.1) 41 | return {"users": list(users_db.values())} 42 | 43 | @app.get("/api/users/{user_id}") 44 | async def get_user(user_id: int): 45 | """Get specific user - demonstrates caching and error handling""" 46 | # Simulate database delay 47 | await asyncio.sleep(0.05) 48 | 49 | if user_id not in users_db: 50 | raise HTTPException(status_code=404, detail="User not found") 51 | 52 | return {"user": users_db[user_id]} 53 | 54 | @app.post("/api/users") 55 | async def create_user(user_data: Dict[str, Any]): 56 | """Create new user - demonstrates rate limiting""" 57 | # Simulate processing time 58 | await asyncio.sleep(0.2) 59 | 60 | new_id = max(users_db.keys()) + 1 if users_db else 1 61 | new_user = { 62 | "id": new_id, 63 | "name": user_data.get("name", "Unknown"), 64 | "email": user_data.get("email", "unknown@example.com"), 65 | "role": user_data.get("role", "user") 66 | } 67 | users_db[new_id] = new_user 68 | 69 | return {"message": "User created", "user": new_user} 70 | 71 | # Product endpoints 72 | @app.get("/api/products") 73 | async def get_products(): 74 | """Get all products - demonstrates caching with longer TTL""" 75 | # Simulate slower database query 76 | await asyncio.sleep(0.3) 77 | return {"products": list(products_db.values())} 78 | 79 | @app.get("/api/products/{product_id}") 80 | async def get_product(product_id: int): 81 | """Get specific product""" 82 | await asyncio.sleep(0.1) 83 | 84 | if product_id not in products_db: 85 | raise HTTPException(status_code=404, detail="Product not found") 86 | 87 | return {"product": products_db[product_id]} 88 | 89 | # Admin endpoints (more restrictive rate limits) 90 | @app.get("/api/admin/stats") 91 | async def get_admin_stats(): 92 | """Admin endpoint with strict rate limiting""" 93 | await asyncio.sleep(0.5) # Simulate expensive operation 94 | 95 | return { 96 | "total_users": len(users_db), 97 | "total_products": len(products_db), 98 | "system_load": random.uniform(0.1, 0.9), 99 | "memory_usage": random.uniform(30, 80), 100 | "timestamp": time.time() 101 | } 102 | 103 | @app.post("/api/admin/cleanup") 104 | async def admin_cleanup(): 105 | """Admin cleanup operation - very restricted""" 106 | await asyncio.sleep(1.0) # Simulate long-running operation 107 | 108 | return { 109 | "message": "Cleanup completed", 110 | "cleaned_items": random.randint(10, 100), 111 | "timestamp": time.time() 112 | } 113 | 114 | # Slow endpoint to test timeouts 115 | @app.get("/api/slow") 116 | async def slow_endpoint(): 117 | """Intentionally slow endpoint to test timeout features""" 118 | # This will sometimes timeout with the configured timeout 119 | delay = random.uniform(3, 8) 120 | await asyncio.sleep(delay) 121 | 122 | return { 123 | "message": f"Completed after {delay:.2f} seconds", 124 | "timestamp": time.time() 125 | } 126 | 127 | # Unreliable endpoint to test retry logic 128 | @app.get("/api/unreliable") 129 | async def unreliable_endpoint(): 130 | """Endpoint that randomly fails to test retry logic""" 131 | if random.random() < 0.6: # 60% chance of failure 132 | raise HTTPException(status_code=500, detail="Random server error") 133 | 134 | return { 135 | "message": "Success on retry!", 136 | "timestamp": time.time() 137 | } 138 | 139 | # Search endpoint with expensive operation 140 | @app.get("/api/search") 141 | async def search(q: str = ""): 142 | """Search endpoint with expensive operation - good for caching""" 143 | await asyncio.sleep(0.8) # Simulate expensive search 144 | 145 | # Mock search results 146 | results = [] 147 | if q: 148 | for user in users_db.values(): 149 | if q.lower() in user["name"].lower() or q.lower() in user["email"].lower(): 150 | results.append({"type": "user", "data": user}) 151 | 152 | for product in products_db.values(): 153 | if q.lower() in product["name"].lower() or q.lower() in product["category"].lower(): 154 | results.append({"type": "product", "data": product}) 155 | 156 | return { 157 | "query": q, 158 | "results": results, 159 | "count": len(results), 160 | "timestamp": time.time() 161 | } 162 | 163 | # Bulk endpoint for testing rate limiting 164 | @app.post("/api/bulk/process") 165 | async def bulk_process(items: List[Dict[str, Any]]): 166 | """Bulk processing endpoint - should be rate limited""" 167 | await asyncio.sleep(0.1 * len(items)) # Processing time based on items 168 | 169 | return { 170 | "message": f"Processed {len(items)} items", 171 | "processed_count": len(items), 172 | "timestamp": time.time() 173 | } 174 | 175 | if __name__ == "__main__": 176 | import uvicorn 177 | uvicorn.run(app, host="0.0.0.0", port=8000) -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Premier 2 | 3 | [![PyPI version](https://badge.fury.io/py/premier.svg)](https://badge.fury.io/py/premier) 4 | [![Python Version](https://img.shields.io/pypi/pyversions/premier.svg)](https://pypi.org/project/premier/) 5 | [![License](https://img.shields.io/github/license/raceychan/premier)](https://github.com/raceychan/premier/blob/master/LICENSE) 6 | 7 | --- 8 | 9 | Premier is a versatile Python toolkit that can be used in three main ways: 10 | 11 | 1. **Lightweight Standalone API Gateway** - Run as a dedicated gateway service 12 | 2. **ASGI App/Middleware** - Wrap existing ASGI applications or add as middleware 13 | 3. **Decorator Mode** - Use Premier decorators directly on functions for maximum flexibility 14 | 15 | Premier transforms any Python web application into a full-featured API gateway with caching, rate limiting, retry logic, timeouts, and performance monitoring. 16 | 17 | Premier comes with a nice dashboard for you to monitor your requests 18 | 19 | ![Premier Dashboard](images/dashboard.png) 20 | 21 | ## Features 22 | 23 | Premier provides enterprise-grade API gateway functionality with: 24 | 25 | - **API Gateway Features** - caching, rate limiting, retry logic, and timeout, etc. 26 | - **Path-Based Policies** - Different features per route with regex matching 27 | - **Load Balancing & Circuit Breaker** - Round robin load balancing with fault tolerance 28 | - **WebSocket Support** - Full WebSocket proxying with rate limiting and monitoring 29 | - **Web Dashboard** - Built-in web GUI for monitoring and configuration management 30 | - **YAML Configuration** - Declarative configuration with namespace support 31 | 32 | ... and more 33 | 34 | ## Why Premier 35 | 36 | Premier is designed for **simplicity and accessibility** - perfect for simple applications that need API gateway functionality without introducing complex tech stacks like Kong, Ambassador, or Istio. 37 | 38 | **Key advantages:** 39 | 40 | - **Zero Code Changes** - Wrap existing ASGI apps without modifications 41 | - **Simple Setup** - Single dependency, no external services required 42 | - **Dual Mode Operation** - Plugin for existing apps OR standalone gateway 43 | - **Python Native** - Built for Python developers, integrates seamlessly 44 | - **Lightweight** - Minimal overhead, maximum performance 45 | - **Hot Reloadable** - Update configurations without restarts 46 | 47 | ## Quick Start 48 | 49 | ### Plugin Mode (Recommended) 50 | 51 | **How it works:** Each app instance has its own Premier gateway wrapper 52 | 53 | ``` 54 | ┌─────────────────────────────────────────────────────────────┐ 55 | │ App Instance 1 │ 56 | │ ┌─────────────────┐ ┌─────────────────────────────────┐ │ 57 | │ │ Premier │────│ Your ASGI App │ │ 58 | │ │ Gateway │ │ (FastAPI/Django/etc) │ │ 59 | │ │ ┌──────────┐ │ │ │ │ 60 | │ │ │Cache │ │ │ @app.get("/api/users") │ │ 61 | │ │ │RateLimit │ │ │ async def get_users(): │ │ 62 | │ │ │Retry │ │ │ return users │ │ 63 | │ │ │Timeout │ │ │ │ │ 64 | │ │ └──────────┘ │ │ │ │ 65 | │ └─────────────────┘ └─────────────────────────────────┘ │ 66 | └─────────────────────────────────────────────────────────────┘ 67 | ``` 68 | 69 | You can keep your existing app.py file untouched 70 | 71 | ```python 72 | # app.py 73 | from premier.asgi import ASGIGateway, GatewayConfig 74 | from fastapi import FastAPI 75 | 76 | # Your existing app - no changes needed 77 | app = FastAPI() 78 | 79 | @app.get("/api/users/{user_id}") 80 | async def get_user(user_id: int): 81 | return await fetch_user_from_database(user_id) 82 | ``` 83 | 84 | Next, import your app instance and wrap it with ASGIGateway: 85 | 86 | ```python 87 | # gateway.py 88 | from .app import app 89 | # Load configuration and wrap app 90 | config = GatewayConfig.from_file("gateway.yaml") 91 | app = ASGIGateway(config=config, app=app) 92 | ``` 93 | 94 | Then, instead of serving the original app directly, serve the one wrapped with ASGIGateway. 95 | 96 | ### Standalone Mode 97 | 98 | **How it works:** Single gateway handles all requests and forwards to backend services 99 | 100 | ``` 101 | ┌─────────────────────┐ 102 | Client Request │ Premier Gateway │ 103 | │ │ ┌──────────────┐ │ 104 | │ │ │ Cache │ │ 105 | └──────────────► │ RateLimit │ │ 106 | │ │ Retry │ │ 107 | │ │ Timeout │ │ 108 | │ │ Monitoring │ │ 109 | │ └──────────────┘ │ 110 | └─────┬──────┬────────┘ 111 | │ │ 112 | ┌─────────┘ └─────────┐ 113 | ▼ ▼ 114 | ┌───────────────┐ ┌───────────────┐ 115 | │ Backend 1 │ │ Backend 2 │ 116 | │ (Any Service) │ │ (Any Service) │ 117 | │ │ │ │ 118 | │ Node.js API │ │ Python API │ 119 | │ Java Service │ │ .NET Service │ 120 | │ Static Files │ │ Database │ 121 | └───────────────┘ └───────────────┘ 122 | ``` 123 | 124 | ```python 125 | # main.py 126 | from premier.asgi import ASGIGateway, GatewayConfig 127 | 128 | config = GatewayConfig.from_file("gateway.yaml") 129 | gateway = ASGIGateway(config, servers=["http://backend:8000"]) 130 | ``` 131 | 132 | ```bash 133 | uvicorn src:main 134 | ``` 135 | 136 | ### Decorator Mode 137 | 138 | **How it works:** Apply Premier features directly to individual functions with decorators - no ASGI app required 139 | 140 | For detailed examples and tutorials, see the [Examples page](examples.md). 141 | 142 | ## Requirements 143 | 144 | - Python >= 3.10 145 | - Redis >= 5.0.3 (optional, for distributed deployments) 146 | - PyYAML (for YAML configuration) 147 | - aiohttp (optional, for standalone mode) 148 | 149 | ## Next Steps 150 | 151 | - [Installation Guide](installation.md) - Install Premier in your project 152 | - [Configuration Reference](configuration.md) - Complete configuration documentation 153 | - [Web Dashboard](web-gui.md) - Monitor and manage your gateway 154 | - [Examples](examples.md) - Complete examples and tutorials 155 | 156 | ## License 157 | 158 | MIT License -------------------------------------------------------------------------------- /tests/test_throttling.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | 7 | from premier import QuotaExceedsError, Throttler 8 | from premier.features.throttler import handler 9 | 10 | 11 | def _keymaker(a: int, b: int) -> str: 12 | return f"{a}" 13 | 14 | 15 | async def test_throttle_raise_error(aiothrottler: Throttler): 16 | quota = 3 17 | 18 | @aiothrottler.fixed_window(quota=quota, duration=5) 19 | def add(a: int, b: int) -> int: # sync function 20 | res = a + b 21 | return res 22 | 23 | # First 3 calls should work 24 | for _ in range(quota): 25 | result = await add(3, 5) 26 | assert result == 8 27 | 28 | # 4th call should raise QuotaExceedsError 29 | with pytest.raises(QuotaExceedsError): 30 | await add(3, 5) 31 | 32 | 33 | async def test_method(aiothrottler: Throttler): 34 | quota = 3 35 | 36 | class T: 37 | @aiothrottler.fixed_window(quota=quota, duration=5) 38 | def add(self, a: int, b: int) -> int: # sync method 39 | res = a + b 40 | return res 41 | 42 | t = T() 43 | # First 3 calls should work 44 | for _ in range(quota): 45 | result = await t.add(3, 5) 46 | assert result == 8 47 | 48 | # 4th call should raise QuotaExceedsError 49 | with pytest.raises(QuotaExceedsError): 50 | await t.add(3, 5) 51 | 52 | 53 | async def test_throttler_do_not_raise_error(): 54 | from unittest.mock import Mock 55 | 56 | from premier.providers import AsyncInMemoryCache 57 | from premier.features.throttler.handler import AsyncDefaultHandler 58 | from premier.features.throttler.throttler import Throttler 59 | 60 | # Create a mock timer that returns values in sequence 61 | mock_timer = Mock() 62 | # Provide enough values for all timer calls during the test 63 | # First 3 calls at t=0, then the final call at t=4 to simulate time progression 64 | mock_timer.side_effect = [0, 0, 0, 0, 4, 4, 6, 6] # Extra values to be safe 65 | 66 | # Create handler with injected timer 67 | handler_with_timer = AsyncDefaultHandler(AsyncInMemoryCache(), timer=mock_timer) 68 | 69 | # Create and configure throttler with custom handler 70 | throttler = Throttler(handler=handler_with_timer, keyspace="test") 71 | await throttler.clear() 72 | 73 | @throttler.fixed_window(quota=2, duration=3) 74 | def add(a: int, b: int) -> int: # sync function 75 | res = a + b 76 | return res 77 | 78 | tries = 2 # Only test the quota limit 79 | res = [] 80 | for _ in range(tries): 81 | res.append(await add(3, 5)) # decorated function is async 82 | assert len(res) == tries 83 | 84 | # The third call should be throttled 85 | try: 86 | await add(3, 5) 87 | assert False, "Should have been throttled" 88 | except QuotaExceedsError: 89 | pass # Expected 90 | 91 | 92 | async def test_throttler_do_not_raise_error_with_interval(aiothrottler: Throttler): 93 | await aiothrottler.clear() 94 | 95 | @aiothrottler.fixed_window(quota=3, duration=5) 96 | def add(a: int, b: int) -> int: # sync function 97 | res = a + b 98 | return res 99 | 100 | tries = 2 101 | res = [await add(3, 5) for _ in range(tries)] # decorated function is async 102 | assert len(res) == tries 103 | 104 | 105 | async def test_throttler_with_keymaker(aiothrottler: Throttler): 106 | await aiothrottler.clear() 107 | 108 | @aiothrottler.fixed_window(quota=3, duration=5, keymaker=_keymaker) 109 | def add(a: int, b: int) -> int: # sync function 110 | res = a + b 111 | return res 112 | 113 | tries = 2 114 | res = [await add(3, 5) for _ in range(tries)] # decorated function is async 115 | assert len(res) == tries 116 | 117 | 118 | async def test_throttler_with_token_bucket(): 119 | from unittest.mock import Mock 120 | 121 | from premier.providers import AsyncInMemoryCache 122 | from premier.features.throttler.handler import AsyncDefaultHandler 123 | from premier.features.throttler.throttler import Throttler 124 | 125 | # Create a mock timer that returns values in sequence 126 | mock_timer = Mock() 127 | # Mock time progression to simulate token bucket refill 128 | mock_timer.side_effect = [ 129 | 0, 130 | 0, 131 | 0, 132 | 0, 133 | 2, 134 | ] # First 3 calls at t=0, 4th fails, 5th at t=2 135 | 136 | # Create handler with injected timer 137 | handler_with_timer = AsyncDefaultHandler(AsyncInMemoryCache(), timer=mock_timer) 138 | 139 | # Create and configure throttler with custom handler 140 | throttler = Throttler(handler=handler_with_timer, keyspace="test") 141 | await throttler.clear() 142 | 143 | @throttler.token_bucket(quota=3, duration=5, keymaker=_keymaker) 144 | def add(a: int, b: int) -> int: # sync function 145 | res = a + b 146 | return res 147 | 148 | tries = 4 149 | res: list[int] = [] 150 | try: 151 | for _ in range(tries): 152 | res.append(await add(3, 5)) # decorated function is async 153 | except QuotaExceedsError: 154 | res.append(await add(3, 5)) # This should work due to mocked time progression 155 | 156 | assert len(res) == tries 157 | 158 | 159 | # BUG: this would leave "p" and "t" to redis and won't be removed 160 | async def test_throttler_with_leaky_bucket(aiothrottler: Throttler): 161 | bucket_size = 3 162 | quota = 1 163 | duration = 1 164 | 165 | @aiothrottler.leaky_bucket( 166 | quota=quota, 167 | bucket_size=bucket_size, 168 | duration=duration, 169 | keymaker=_keymaker, 170 | ) 171 | def add(a: int, b: int) -> None: # sync function 172 | # Remove sleep to speed up test 173 | return None 174 | 175 | # Test concurrent requests to fill bucket capacity 176 | # Create tasks concurrently so they hit the bucket at the same time 177 | todo = set[asyncio.Task[None]]() 178 | rejected = 0 179 | 180 | tries = 6 181 | for _ in range(tries): 182 | task = asyncio.create_task(add(3, 5)) 183 | todo.add(task) 184 | done, _ = await asyncio.wait(todo) 185 | 186 | for e in done: 187 | try: 188 | e.result() 189 | except QuotaExceedsError: 190 | rejected += 1 191 | 192 | # In leaky bucket: bucket_size allows immediate tasks, quota determines rate 193 | # So we expect bucket_size tasks to succeed immediately, rest rejected 194 | expected_rejected = tries - bucket_size 195 | assert rejected == expected_rejected 196 | -------------------------------------------------------------------------------- /premier/features/retry.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | import time 4 | from collections.abc import Callable 5 | from typing import Awaitable, Optional, TypeVar, Union 6 | 7 | from typing_extensions import assert_never 8 | 9 | from premier.interface import P 10 | from premier.features.timer import ILogger 11 | from premier.features.retry_errors import CircuitBreakerOpenError 12 | 13 | T = TypeVar("T") 14 | 15 | N = float | int 16 | 17 | WaitStrategy = Union[N, list[N], Callable[[int], N]] 18 | 19 | 20 | def _wait_time_calculator_factory(wait: WaitStrategy) -> Callable[[int], float]: 21 | match wait: 22 | case int(): 23 | 24 | def algo(_: int) -> float: 25 | return wait 26 | 27 | case float(): 28 | 29 | def algo(_: int) -> float: 30 | return wait 31 | 32 | case list(): 33 | 34 | def algo(attempts: int) -> float: 35 | return wait[attempts] 36 | 37 | case Callable(): 38 | 39 | def algo(attempts: int) -> float: 40 | return wait(attempts) 41 | 42 | case _ as unreacheable: 43 | assert_never(unreacheable) 44 | return algo 45 | 46 | 47 | # TODO: 1. add `on_fail` callback, receives *args, **kwargs, return R 48 | # circuitbreaker: bool, if True, call on_fail without calling function 49 | 50 | 51 | def retry( 52 | max_attempts: int = 3, 53 | wait: WaitStrategy = 1, 54 | exceptions: tuple[type[Exception], ...] = (Exception,), # TODO: retry on, raise on 55 | on_fail: Callable[P, Awaitable[None]] | None = None, 56 | logger: Optional[ILogger] = None, 57 | ) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]: 58 | """ 59 | Retry decorator for async functions with configurable wait strategies. 60 | 61 | Args: 62 | max_attempts: Maximum number of retry attempts 63 | wait: Wait strategy - int (fixed seconds), list[int] (per-attempt seconds), 64 | or callable (function of attempt number) 65 | exceptions: Tuple of exception types to retry on 66 | on_fail: Optional callback to execute on failure 67 | logger: Optional logger to log retry attempts 68 | """ 69 | get_wait_time = _wait_time_calculator_factory(wait) 70 | 71 | def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]: 72 | @functools.wraps(func) 73 | async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: 74 | last_exception: Exception | None = None 75 | 76 | for attempt in range(max_attempts): 77 | try: 78 | if logger and attempt > 0: 79 | logger.info(f"{func.__name__}: Retry attempt {attempt + 1}/{max_attempts}") 80 | return await func(*args, **kwargs) 81 | except exceptions as e: 82 | last_exception = e 83 | 84 | if attempt == max_attempts - 1: 85 | if logger: 86 | logger.exception(f"{func.__name__}: All {max_attempts} retry attempts failed. Last error: {e}") 87 | break 88 | 89 | if logger: 90 | wait_time = get_wait_time(attempt) 91 | logger.info(f"{func.__name__}: Attempt {attempt + 1}/{max_attempts} failed with {type(e).__name__}: {e}. Retrying in {wait_time}s...") 92 | 93 | if on_fail is not None: 94 | await on_fail(*args, **kwargs) 95 | 96 | wait_time = get_wait_time(attempt) 97 | if wait_time > 0: 98 | await asyncio.sleep(wait_time) 99 | 100 | assert last_exception 101 | raise last_exception 102 | 103 | return wrapper 104 | 105 | return decorator 106 | 107 | 108 | class CircuitBreaker: 109 | """Circuit breaker implementation for fault tolerance.""" 110 | 111 | def __init__( 112 | self, 113 | failure_threshold: int = 5, 114 | recovery_timeout: float = 60.0, 115 | expected_exception: type[Exception] = Exception, 116 | ): 117 | """ 118 | Initialize circuit breaker. 119 | 120 | Args: 121 | failure_threshold: Number of failures before opening circuit 122 | recovery_timeout: Time in seconds before attempting to recover 123 | expected_exception: Exception type to track for failures 124 | """ 125 | self.failure_threshold = failure_threshold 126 | self.recovery_timeout = recovery_timeout 127 | self.expected_exception = expected_exception 128 | 129 | self.failure_count = 0 130 | self.last_failure_time: Optional[float] = None 131 | self.state = "CLOSED" # CLOSED, OPEN, HALF_OPEN 132 | 133 | def __call__(self, func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]: 134 | """Decorate function with circuit breaker.""" 135 | @functools.wraps(func) 136 | async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: 137 | if self.state == "OPEN": 138 | if self._should_attempt_reset(): 139 | self.state = "HALF_OPEN" 140 | else: 141 | raise CircuitBreakerOpenError( 142 | f"Circuit breaker is OPEN. Last failure: {self.last_failure_time}" 143 | ) 144 | 145 | try: 146 | result = await func(*args, **kwargs) 147 | self._on_success() 148 | return result 149 | except self.expected_exception as e: 150 | self._on_failure() 151 | raise e 152 | 153 | return wrapper 154 | 155 | def _should_attempt_reset(self) -> bool: 156 | """Check if enough time has passed to attempt reset.""" 157 | if self.last_failure_time is None: 158 | return True 159 | return time.time() - self.last_failure_time >= self.recovery_timeout 160 | 161 | def _on_success(self): 162 | """Handle successful function call.""" 163 | self.failure_count = 0 164 | self.state = "CLOSED" 165 | 166 | def _on_failure(self): 167 | """Handle failed function call.""" 168 | self.failure_count += 1 169 | self.last_failure_time = time.time() 170 | 171 | if self.failure_count >= self.failure_threshold: 172 | self.state = "OPEN" 173 | 174 | 175 | # Use CircuitBreakerOpenError from retry_errors.py instead 176 | CircuitBreakerOpenException = CircuitBreakerOpenError # Backward compatibility alias 177 | -------------------------------------------------------------------------------- /premier/main.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from premier.features.cache import Cache 4 | from premier.providers import AsyncCacheProvider, AsyncInMemoryCache 5 | from premier.features.retry import retry 6 | from premier.features.throttler import Throttler, ThrottleAlgo, KeyMaker 7 | from premier.features.throttler.handler import AsyncDefaultHandler 8 | from premier.features.timer import timeout, timeit, ILogger 9 | 10 | 11 | class Premier: 12 | """ 13 | Facade class that provides a unified interface for all Premier functionality. 14 | 15 | This class follows the facade pattern to simplify the usage of caching, 16 | throttling, retry, and timing utilities by providing a single entry point 17 | with sensible defaults. 18 | """ 19 | 20 | def __init__( 21 | self, 22 | cache_provider: Optional[AsyncCacheProvider] = None, 23 | throttler: Optional[Throttler] = None, 24 | cache: Optional[Cache] = None, 25 | keyspace: str = "premier", 26 | ): 27 | """ 28 | Initialize Premier facade with optional components. 29 | 30 | Args: 31 | cache_provider: Cache provider for both caching and throttling 32 | throttler: Custom throttler instance 33 | cache: Custom cache instance 34 | keyspace: Default keyspace prefix for all operations 35 | """ 36 | self._keyspace = keyspace 37 | 38 | # Initialize cache provider (shared between cache and throttler) 39 | self._cache_provider = cache_provider or AsyncInMemoryCache() 40 | 41 | # Initialize cache 42 | self._cache = cache or Cache( 43 | cache_provider=self._cache_provider, 44 | keyspace=f"{keyspace}:cache" 45 | ) 46 | 47 | # Initialize throttler 48 | if throttler: 49 | self._throttler = throttler 50 | else: 51 | handler = AsyncDefaultHandler(self._cache_provider) 52 | self._throttler = Throttler( 53 | handler=handler, 54 | keyspace=f"{keyspace}:throttler" 55 | ) 56 | 57 | @property 58 | def cache(self) -> Cache: 59 | """Get the cache instance.""" 60 | return self._cache 61 | 62 | @property 63 | def throttler(self) -> Throttler: 64 | """Get the throttler instance.""" 65 | return self._throttler 66 | 67 | @property 68 | def cache_provider(self) -> AsyncCacheProvider: 69 | """Get the cache provider instance.""" 70 | return self._cache_provider 71 | 72 | # Cache methods 73 | def cache_result(self, expire_s: Optional[int] = None, cache_key: Optional[str] = None): 74 | """ 75 | Cache decorator for function results. 76 | 77 | Args: 78 | expire_s: Expiration time in seconds 79 | cache_key: Custom cache key 80 | """ 81 | return self._cache.cache(expire_s=expire_s, cache_key=cache_key) 82 | 83 | async def clear_cache(self, keyspace: Optional[str] = None): 84 | """Clear cache entries.""" 85 | await self._cache.clear(keyspace) 86 | 87 | # Throttling methods 88 | def fixed_window(self, quota: int, duration: int, keymaker: Optional[KeyMaker] = None): 89 | """Fixed window rate limiting decorator.""" 90 | return self._throttler.fixed_window(quota, duration, keymaker) 91 | 92 | def sliding_window(self, quota: int, duration: int, keymaker: Optional[KeyMaker] = None): 93 | """Sliding window rate limiting decorator.""" 94 | return self._throttler.sliding_window(quota, duration, keymaker) 95 | 96 | def token_bucket(self, quota: int, duration: int, keymaker: Optional[KeyMaker] = None): 97 | """Token bucket rate limiting decorator.""" 98 | return self._throttler.token_bucket(quota, duration, keymaker) 99 | 100 | def leaky_bucket(self, bucket_size: int, quota: int, duration: int, keymaker: Optional[KeyMaker] = None): 101 | """Leaky bucket rate limiting decorator.""" 102 | return self._throttler.leaky_bucket(quota, bucket_size, duration, keymaker) 103 | 104 | def throttle( 105 | self, 106 | algo: ThrottleAlgo, 107 | quota: int, 108 | duration: int, 109 | keymaker: Optional[KeyMaker] = None, 110 | bucket_size: int = -1, 111 | ): 112 | """ 113 | Generic throttle decorator. 114 | 115 | Args: 116 | algo: Throttling algorithm to use 117 | quota: Number of requests allowed 118 | duration: Time window in seconds 119 | keymaker: Function to generate custom keys 120 | bucket_size: Bucket size for leaky bucket algorithm 121 | """ 122 | return self._throttler.throttle(algo, quota, duration, keymaker, bucket_size) 123 | 124 | async def clear_throttle(self, keyspace: Optional[str] = None): 125 | """Clear throttling state.""" 126 | await self._throttler.clear(keyspace) 127 | 128 | # Retry methods 129 | @staticmethod 130 | def retry( 131 | max_attempts: int = 3, 132 | wait: float = 1.0, 133 | exceptions: tuple = (Exception,), 134 | logger: Optional[ILogger] = None, 135 | ): 136 | """ 137 | Retry decorator for functions. 138 | 139 | Args: 140 | max_attempts: Maximum number of retry attempts 141 | wait: Wait strategy (seconds) 142 | exceptions: Exceptions to retry on 143 | logger: Logger to log retry attempts 144 | """ 145 | return retry( 146 | max_attempts=max_attempts, 147 | wait=wait, 148 | exceptions=exceptions, 149 | logger=logger, 150 | ) 151 | 152 | # Timing methods 153 | @staticmethod 154 | def timeout(seconds: float, logger: Optional[ILogger] = None): 155 | """ 156 | Timeout decorator for functions. 157 | 158 | Args: 159 | seconds: Timeout duration in seconds 160 | logger: Logger to log timeout events 161 | """ 162 | return timeout(seconds, logger=logger) 163 | 164 | @staticmethod 165 | def timeit( 166 | logger: Optional[ILogger] = None, 167 | precision: int = 2, 168 | log_threshold: float = 0.1, 169 | with_args: bool = False, 170 | show_fino: bool = True, 171 | ): 172 | """ 173 | Timing decorator for functions. 174 | 175 | Args: 176 | logger: Logger instance for timing output 177 | precision: Decimal precision for timing 178 | log_threshold: Minimum time to log 179 | with_args: Include function arguments in log 180 | show_fino: Show file info in log 181 | """ 182 | return timeit( 183 | logger=logger, 184 | precision=precision, 185 | log_threshold=log_threshold, 186 | with_args=with_args, 187 | show_fino=show_fino, 188 | ) 189 | 190 | async def close(self): 191 | """Close all resources.""" 192 | await self._cache_provider.close() -------------------------------------------------------------------------------- /tests/test_facade.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import pytest 3 | from unittest.mock import AsyncMock 4 | 5 | from premier import Premier 6 | from premier.providers.memory import AsyncInMemoryCache 7 | from premier.features.throttler.errors import QuotaExceedsError 8 | from premier.features.timer_errors import TimeoutError as PremierTimeoutError 9 | 10 | 11 | class TestPremierFacade: 12 | 13 | @pytest.fixture 14 | def premier(self): 15 | """Create a Premier instance for testing.""" 16 | return Premier(keyspace="test") 17 | 18 | @pytest.mark.asyncio 19 | async def test_premier_initialization(self, premier): 20 | """Test Premier facade initialization.""" 21 | assert premier.cache is not None 22 | assert premier.throttler is not None 23 | assert premier.cache_provider is not None 24 | assert isinstance(premier.cache_provider, AsyncInMemoryCache) 25 | 26 | @pytest.mark.asyncio 27 | async def test_premier_with_custom_cache_provider(self): 28 | """Test Premier with custom cache provider.""" 29 | custom_provider = AsyncInMemoryCache() 30 | premier = Premier(cache_provider=custom_provider) 31 | 32 | assert premier.cache_provider is custom_provider 33 | 34 | @pytest.mark.asyncio 35 | async def test_cache_functionality(self, premier): 36 | """Test caching functionality through facade.""" 37 | 38 | @premier.cache_result(expire_s=60) 39 | async def expensive_function(x): 40 | return x * 2 41 | 42 | # First call should execute the function 43 | result1 = await expensive_function(5) 44 | assert result1 == 10 45 | 46 | # Second call should return cached result 47 | result2 = await expensive_function(5) 48 | assert result2 == 10 49 | 50 | @pytest.mark.asyncio 51 | async def test_throttling_functionality(self, premier): 52 | """Test throttling functionality through facade.""" 53 | 54 | @premier.fixed_window(quota=2, duration=1) 55 | async def throttled_function(): 56 | return "success" 57 | 58 | # First two calls should succeed 59 | result1 = await throttled_function() 60 | assert result1 == "success" 61 | 62 | result2 = await throttled_function() 63 | assert result2 == "success" 64 | 65 | # Third call should raise QuotaExceedsError 66 | with pytest.raises(QuotaExceedsError): 67 | await throttled_function() 68 | 69 | @pytest.mark.asyncio 70 | async def test_leaky_bucket_functionality(self, premier): 71 | """Test leaky bucket functionality through facade.""" 72 | 73 | @premier.leaky_bucket(bucket_size=2, quota=1, duration=1) 74 | async def leaky_function(): 75 | return "success" 76 | 77 | # First call should succeed immediately 78 | result = await leaky_function() 79 | assert result == "success" 80 | 81 | @pytest.mark.asyncio 82 | async def test_retry_functionality(self, premier): 83 | """Test retry functionality through facade.""" 84 | call_count = 0 85 | 86 | @premier.retry(max_attempts=3, wait=0.1) 87 | async def flaky_function(): 88 | nonlocal call_count 89 | call_count += 1 90 | if call_count < 3: 91 | raise ValueError("Temporary failure") 92 | return "success" 93 | 94 | result = await flaky_function() 95 | assert result == "success" 96 | assert call_count == 3 97 | 98 | @pytest.mark.asyncio 99 | async def test_retry_with_logger(self, premier): 100 | """Test retry functionality with logger.""" 101 | from unittest.mock import Mock 102 | 103 | logger_mock = Mock() 104 | logger_mock.info = Mock() 105 | logger_mock.exception = Mock() 106 | 107 | call_count = 0 108 | 109 | @premier.retry(max_attempts=3, wait=0.1, logger=logger_mock) 110 | async def flaky_function(): 111 | nonlocal call_count 112 | call_count += 1 113 | if call_count < 3: 114 | raise ValueError("Temporary failure") 115 | return "success" 116 | 117 | result = await flaky_function() 118 | assert result == "success" 119 | assert call_count == 3 120 | 121 | # Verify logger was called 122 | assert logger_mock.info.called 123 | 124 | def test_timeout_functionality(self, premier): 125 | """Test timeout functionality through facade.""" 126 | 127 | @premier.timeout(0.1) 128 | async def slow_function(): 129 | await asyncio.sleep(0.2) 130 | return "too slow" 131 | 132 | async def run_test(): 133 | with pytest.raises(PremierTimeoutError): 134 | await slow_function() 135 | 136 | asyncio.run(run_test()) 137 | 138 | def test_timeit_functionality(self, premier): 139 | """Test timing functionality through facade.""" 140 | 141 | @premier.timeit() 142 | def timed_function(): 143 | return "timed" 144 | 145 | result = timed_function() 146 | assert result == "timed" 147 | 148 | @pytest.mark.asyncio 149 | async def test_clear_cache(self, premier): 150 | """Test cache clearing through facade.""" 151 | 152 | @premier.cache_result() 153 | async def cached_function(x): 154 | return x * 2 155 | 156 | # Cache a result 157 | await cached_function(5) 158 | 159 | # Clear cache 160 | await premier.clear_cache() 161 | 162 | # Should work without issues 163 | result = await cached_function(5) 164 | assert result == 10 165 | 166 | @pytest.mark.asyncio 167 | async def test_clear_throttle(self, premier): 168 | """Test throttle clearing through facade.""" 169 | 170 | @premier.fixed_window(quota=1, duration=10) 171 | async def throttled_function(): 172 | return "success" 173 | 174 | # Use up the quota 175 | await throttled_function() 176 | 177 | # Clear throttle state 178 | await premier.clear_throttle() 179 | 180 | # Should be able to call again 181 | result = await throttled_function() 182 | assert result == "success" 183 | 184 | @pytest.mark.asyncio 185 | async def test_generic_throttle_method(self, premier): 186 | """Test generic throttle method through facade.""" 187 | from premier.features.throttler import ThrottleAlgo 188 | 189 | @premier.throttle( 190 | algo=ThrottleAlgo.TOKEN_BUCKET, 191 | quota=2, 192 | duration=1 193 | ) 194 | async def throttled_function(): 195 | return "success" 196 | 197 | result = await throttled_function() 198 | assert result == "success" 199 | 200 | @pytest.mark.asyncio 201 | async def test_close_resources(self, premier): 202 | """Test resource cleanup through facade.""" 203 | await premier.close() 204 | 205 | # After closing, the cache provider should be closed 206 | # This is mainly to ensure no exceptions are raised 207 | assert True -------------------------------------------------------------------------------- /tests/test_circuit_breaker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import pytest 3 | import time 4 | from unittest.mock import AsyncMock 5 | 6 | from premier.features.retry import CircuitBreaker, CircuitBreakerOpenException 7 | 8 | 9 | class TestCircuitBreaker: 10 | def test_initialization(self): 11 | cb = CircuitBreaker( 12 | failure_threshold=3, 13 | recovery_timeout=30.0, 14 | expected_exception=ValueError, 15 | ) 16 | 17 | assert cb.failure_threshold == 3 18 | assert cb.recovery_timeout == 30.0 19 | assert cb.expected_exception == ValueError 20 | assert cb.failure_count == 0 21 | assert cb.last_failure_time is None 22 | assert cb.state == "CLOSED" 23 | 24 | def test_default_initialization(self): 25 | cb = CircuitBreaker() 26 | 27 | assert cb.failure_threshold == 5 28 | assert cb.recovery_timeout == 60.0 29 | assert cb.expected_exception == Exception 30 | assert cb.state == "CLOSED" 31 | 32 | @pytest.mark.asyncio 33 | async def test_successful_calls_keep_circuit_closed(self): 34 | cb = CircuitBreaker(failure_threshold=3, recovery_timeout=1.0) 35 | 36 | @cb 37 | async def success_func(): 38 | return "success" 39 | 40 | # Multiple successful calls should keep circuit closed 41 | for _ in range(10): 42 | result = await success_func() 43 | assert result == "success" 44 | assert cb.state == "CLOSED" 45 | assert cb.failure_count == 0 46 | 47 | @pytest.mark.asyncio 48 | async def test_circuit_opens_after_threshold_failures(self): 49 | cb = CircuitBreaker(failure_threshold=3, recovery_timeout=1.0, expected_exception=ValueError) 50 | 51 | @cb 52 | async def failing_func(): 53 | raise ValueError("Test error") 54 | 55 | # First 3 failures should not open circuit but increment failure count 56 | for i in range(3): 57 | with pytest.raises(ValueError): 58 | await failing_func() 59 | 60 | if i < 2: # Before threshold 61 | assert cb.state == "CLOSED" 62 | assert cb.failure_count == i + 1 63 | else: # At threshold 64 | assert cb.state == "OPEN" 65 | assert cb.failure_count == 3 66 | 67 | @pytest.mark.asyncio 68 | async def test_circuit_breaker_open_exception(self): 69 | cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1.0, expected_exception=ValueError) 70 | 71 | @cb 72 | async def failing_func(): 73 | raise ValueError("Test error") 74 | 75 | # Trigger circuit opening 76 | for _ in range(2): 77 | with pytest.raises(ValueError): 78 | await failing_func() 79 | 80 | assert cb.state == "OPEN" 81 | 82 | # Further calls should raise CircuitBreakerOpenException 83 | with pytest.raises(CircuitBreakerOpenException): 84 | await failing_func() 85 | 86 | @pytest.mark.asyncio 87 | async def test_circuit_recovery_after_timeout(self): 88 | cb = CircuitBreaker(failure_threshold=2, recovery_timeout=0.1, expected_exception=ValueError) 89 | 90 | call_count = 0 91 | 92 | @cb 93 | async def recovery_func(): 94 | nonlocal call_count 95 | call_count += 1 96 | if call_count <= 2: 97 | raise ValueError("Initial failures") 98 | return "recovered" 99 | 100 | # Trigger circuit opening 101 | for _ in range(2): 102 | with pytest.raises(ValueError): 103 | await recovery_func() 104 | 105 | assert cb.state == "OPEN" 106 | 107 | # Wait for recovery timeout 108 | await asyncio.sleep(0.15) 109 | 110 | # Next call should attempt recovery (HALF_OPEN state) 111 | result = await recovery_func() 112 | assert result == "recovered" 113 | assert cb.state == "CLOSED" 114 | assert cb.failure_count == 0 115 | 116 | @pytest.mark.asyncio 117 | async def test_half_open_failure_reopens_circuit(self): 118 | cb = CircuitBreaker(failure_threshold=2, recovery_timeout=0.1, expected_exception=ValueError) 119 | 120 | call_count = 0 121 | 122 | @cb 123 | async def half_open_fail_func(): 124 | nonlocal call_count 125 | call_count += 1 126 | if call_count <= 3: # First 2 to open, 3rd to test half-open failure 127 | raise ValueError("Test error") 128 | return "success" 129 | 130 | # Trigger circuit opening 131 | for _ in range(2): 132 | with pytest.raises(ValueError): 133 | await half_open_fail_func() 134 | 135 | assert cb.state == "OPEN" 136 | 137 | # Wait for recovery timeout 138 | await asyncio.sleep(0.15) 139 | 140 | # Half-open call that fails should reopen circuit 141 | with pytest.raises(ValueError): 142 | await half_open_fail_func() 143 | 144 | assert cb.state == "OPEN" 145 | 146 | @pytest.mark.asyncio 147 | async def test_only_expected_exceptions_trigger_circuit(self): 148 | cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1.0, expected_exception=ValueError) 149 | 150 | @cb 151 | async def mixed_exception_func(exception_type): 152 | if exception_type == "value_error": 153 | raise ValueError("Value error") 154 | elif exception_type == "type_error": 155 | raise TypeError("Type error") 156 | return "success" 157 | 158 | # TypeError should not trigger circuit breaker 159 | with pytest.raises(TypeError): 160 | await mixed_exception_func("type_error") 161 | 162 | assert cb.state == "CLOSED" 163 | assert cb.failure_count == 0 164 | 165 | # ValueError should trigger circuit breaker 166 | with pytest.raises(ValueError): 167 | await mixed_exception_func("value_error") 168 | 169 | assert cb.state == "CLOSED" 170 | assert cb.failure_count == 1 171 | 172 | @pytest.mark.asyncio 173 | async def test_success_resets_failure_count(self): 174 | cb = CircuitBreaker(failure_threshold=3, recovery_timeout=1.0, expected_exception=ValueError) 175 | 176 | call_count = 0 177 | 178 | @cb 179 | async def reset_func(): 180 | nonlocal call_count 181 | call_count += 1 182 | if call_count == 1: 183 | raise ValueError("First failure") 184 | return "success" 185 | 186 | # First call fails 187 | with pytest.raises(ValueError): 188 | await reset_func() 189 | 190 | assert cb.failure_count == 1 191 | 192 | # Second call succeeds - should reset failure count 193 | result = await reset_func() 194 | assert result == "success" 195 | assert cb.failure_count == 0 196 | assert cb.state == "CLOSED" 197 | 198 | def test_should_attempt_reset_logic(self): 199 | cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1.0) 200 | 201 | # No previous failure 202 | assert cb._should_attempt_reset() is True 203 | 204 | # Recent failure 205 | cb.last_failure_time = time.time() 206 | assert cb._should_attempt_reset() is False 207 | 208 | # Old failure 209 | cb.last_failure_time = time.time() - 2.0 210 | assert cb._should_attempt_reset() is True 211 | 212 | @pytest.mark.asyncio 213 | async def test_circuit_breaker_with_async_mock(self): 214 | cb = CircuitBreaker(failure_threshold=2, recovery_timeout=0.1) 215 | 216 | mock_func = AsyncMock(side_effect=ValueError("Mock error")) 217 | decorated_func = cb(mock_func) 218 | 219 | # Trigger circuit opening 220 | for _ in range(2): 221 | with pytest.raises(ValueError): 222 | await decorated_func() 223 | 224 | assert cb.state == "OPEN" 225 | assert mock_func.call_count == 2 226 | 227 | # Circuit is open, should not call the function 228 | with pytest.raises(CircuitBreakerOpenException): 229 | await decorated_func() 230 | 231 | assert mock_func.call_count == 2 # Should not increment -------------------------------------------------------------------------------- /premier/dashboard/service.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Any, Callable, Dict, Optional 4 | 5 | from .dashboard import DashboardHandler 6 | 7 | 8 | class DashboardService: 9 | """ 10 | Dashboard service that handles all dashboard-related HTTP requests. 11 | 12 | This service acts as a callable ASGI application for dashboard endpoints, 13 | encapsulating all dashboard logic in one place. 14 | """ 15 | 16 | def __init__(self, config_file_path: Optional[str] = None): 17 | """ 18 | Initialize the dashboard service. 19 | 20 | Args: 21 | config_file_path: Path to the configuration file for dashboard config operations 22 | """ 23 | self._handler = DashboardHandler(config_file_path) 24 | 25 | def record_request( 26 | self, 27 | method: str, 28 | path: str, 29 | status: int, 30 | response_time: float, 31 | cache_hit: bool = False, 32 | ): 33 | """Record a request for stats tracking.""" 34 | self._handler.record_request(method, path, status, response_time, cache_hit) 35 | 36 | def get_stats_json(self) -> Dict[str, Any]: 37 | """Get current stats as JSON-serializable dict.""" 38 | return self._handler.stats.asdict() 39 | 40 | async def __call__(self, scope: dict, receive: Callable, send: Callable): 41 | """ 42 | ASGI callable for handling dashboard requests. 43 | 44 | Args: 45 | scope: ASGI scope dict 46 | receive: ASGI receive callable 47 | send: ASGI send callable 48 | """ 49 | path = scope["path"] 50 | method = scope["method"] 51 | 52 | if path == "/premier/dashboard" and method == "GET": 53 | await self._handle_dashboard_html(send) 54 | 55 | elif path == "/premier/dashboard/api/stats" and method == "GET": 56 | await self._handle_stats_api(send) 57 | 58 | elif path == "/premier/dashboard/api/policies" and method == "GET": 59 | await self._handle_policies_api(send) 60 | 61 | elif path == "/premier/dashboard/api/config" and method == "GET": 62 | await self._handle_config_get_api(send) 63 | 64 | elif path == "/premier/dashboard/api/config" and method == "PUT": 65 | await self._handle_config_put_api(receive, send) 66 | 67 | elif path == "/premier/dashboard/api/config/validate" and method == "POST": 68 | await self._handle_config_validate_api(receive, send) 69 | 70 | else: 71 | await self._handle_not_found(send) 72 | 73 | async def _handle_dashboard_html(self, send: Callable): 74 | """Handle dashboard HTML page request.""" 75 | html_path = Path(__file__).parent / "dashboard.html" 76 | try: 77 | with open(html_path, "r", encoding="utf-8") as f: 78 | content = f.read() 79 | await send( 80 | { 81 | "type": "http.response.start", 82 | "status": 200, 83 | "headers": [[b"content-type", b"text/html; charset=utf-8"]], 84 | } 85 | ) 86 | await send( 87 | { 88 | "type": "http.response.body", 89 | "body": content.encode("utf-8"), 90 | } 91 | ) 92 | except FileNotFoundError: 93 | await send( 94 | { 95 | "type": "http.response.start", 96 | "status": 404, 97 | "headers": [[b"content-type", b"text/plain"]], 98 | } 99 | ) 100 | await send( 101 | { 102 | "type": "http.response.body", 103 | "body": b"Dashboard not found", 104 | } 105 | ) 106 | 107 | async def _handle_stats_api(self, send: Callable): 108 | """Handle stats API request.""" 109 | await send( 110 | { 111 | "type": "http.response.start", 112 | "status": 200, 113 | "headers": [[b"content-type", b"application/json"]], 114 | } 115 | ) 116 | await send( 117 | { 118 | "type": "http.response.body", 119 | "body": self._handler.get_stats_json(), 120 | } 121 | ) 122 | 123 | async def _handle_policies_api(self, send: Callable): 124 | """Handle policies API request.""" 125 | config_dict = None 126 | if self._handler.config_path: 127 | config_dict = self._handler.load_config_dict() 128 | await send( 129 | { 130 | "type": "http.response.start", 131 | "status": 200, 132 | "headers": [[b"content-type", b"application/json"]], 133 | } 134 | ) 135 | await send( 136 | { 137 | "type": "http.response.body", 138 | "body": self._handler.get_policies_json(config_dict), 139 | } 140 | ) 141 | 142 | async def _handle_config_get_api(self, send: Callable): 143 | """Handle config GET API request.""" 144 | config_yaml = self._handler.load_config_yaml() 145 | await send( 146 | { 147 | "type": "http.response.start", 148 | "status": 200, 149 | "headers": [[b"content-type", b"text/plain; charset=utf-8"]], 150 | } 151 | ) 152 | await send( 153 | { 154 | "type": "http.response.body", 155 | "body": config_yaml.encode("utf-8"), 156 | } 157 | ) 158 | 159 | async def _handle_config_put_api(self, receive: Callable, send: Callable): 160 | """Handle config PUT API request.""" 161 | body = b"" 162 | while True: 163 | message = await receive() 164 | if message["type"] == "http.request": 165 | body += message.get("body", b"") 166 | if not message.get("more_body", False): 167 | break 168 | 169 | config_yaml = body.decode("utf-8") 170 | if self._handler.save_config_yaml(config_yaml): 171 | await send( 172 | { 173 | "type": "http.response.start", 174 | "status": 200, 175 | "headers": [[b"content-type", b"text/plain"]], 176 | } 177 | ) 178 | await send( 179 | { 180 | "type": "http.response.body", 181 | "body": b"Configuration saved successfully", 182 | } 183 | ) 184 | else: 185 | await send( 186 | { 187 | "type": "http.response.start", 188 | "status": 400, 189 | "headers": [[b"content-type", b"text/plain"]], 190 | } 191 | ) 192 | await send( 193 | { 194 | "type": "http.response.body", 195 | "body": b"Failed to save configuration", 196 | } 197 | ) 198 | 199 | async def _handle_config_validate_api(self, receive: Callable, send: Callable): 200 | """Handle config validation API request.""" 201 | body = b"" 202 | while True: 203 | message = await receive() 204 | if message["type"] == "http.request": 205 | body += message.get("body", b"") 206 | if not message.get("more_body", False): 207 | break 208 | 209 | config_yaml = body.decode("utf-8") 210 | result = self._handler.validate_config_yaml(config_yaml) 211 | result_json = json.dumps(result) 212 | await send( 213 | { 214 | "type": "http.response.start", 215 | "status": 200, 216 | "headers": [[b"content-type", b"application/json"]], 217 | } 218 | ) 219 | await send( 220 | { 221 | "type": "http.response.body", 222 | "body": result_json.encode(), 223 | } 224 | ) 225 | 226 | async def _handle_not_found(self, send: Callable): 227 | """Handle 404 for unknown dashboard endpoints.""" 228 | await send( 229 | { 230 | "type": "http.response.start", 231 | "status": 404, 232 | "headers": [[b"content-type", b"text/plain"]], 233 | } 234 | ) 235 | await send( 236 | { 237 | "type": "http.response.body", 238 | "body": b"Dashboard endpoint not found", 239 | } 240 | ) 241 | -------------------------------------------------------------------------------- /premier/asgi/forward.py: -------------------------------------------------------------------------------- 1 | """ 2 | Request forwarding service for ASGI applications. 3 | 4 | This module provides the ForwardService class that handles HTTP and WebSocket 5 | request forwarding to backend servers with load balancing support. 6 | """ 7 | 8 | import asyncio 9 | from typing import Callable, Dict, List, Optional 10 | 11 | from .loadbalancer import ILoadBalancer, RandomLoadBalancer 12 | 13 | try: 14 | import aiohttp 15 | except ImportError: 16 | raise RuntimeError( 17 | "aiohttp is required when servers parameter is used. Install with: pip install aiohttp" 18 | ) 19 | 20 | 21 | class ForwardService: 22 | """Service for forwarding HTTP and WebSocket requests to backend servers.""" 23 | 24 | def __init__( 25 | self, 26 | servers: list[str] | None, 27 | lb_factory: Callable[[List[str]], ILoadBalancer] = RandomLoadBalancer, 28 | session: Optional[aiohttp.ClientSession] = None, 29 | ): 30 | """ 31 | Initialize the ForwardService. 32 | 33 | Args: 34 | load_balancer: Load balancer for selecting backend servers 35 | session: Optional aiohttp ClientSession (will create one if not provided) 36 | """ 37 | self.lb = lb_factory(servers or []) 38 | self._session = session or aiohttp.ClienSession() 39 | self._hop_by_hop_headers = { 40 | "connection", 41 | "keep-alive", 42 | "proxy-authenticate", 43 | "proxy-authorization", 44 | "te", 45 | "trailers", 46 | "transfer-encoding", 47 | "upgrade", 48 | } 49 | 50 | async def close(self): 51 | """Close the aiohttp session.""" 52 | if self._session: 53 | await self._session.close() 54 | 55 | def _build_target_url(self, server_url: str, path: str, query_string: str) -> str: 56 | """Build the target URL from server URL, path, and query string.""" 57 | target_url = f"{server_url.rstrip('/')}{path}" 58 | if query_string: 59 | target_url += f"?{query_string}" 60 | return target_url 61 | 62 | def _extract_headers(self, scope: dict) -> Dict[str, str]: 63 | """Extract and clean headers from ASGI scope.""" 64 | headers = {} 65 | for header_name, header_value in scope.get("headers", []): 66 | name = header_name.decode("latin1") 67 | value = header_value.decode("latin1") 68 | headers[name] = value 69 | 70 | # Remove hop-by-hop headers 71 | return { 72 | k: v 73 | for k, v in headers.items() 74 | if k.lower() not in self._hop_by_hop_headers 75 | } 76 | 77 | async def _collect_request_body(self, receive: Callable) -> bytes: 78 | """Collect the complete request body from ASGI receive callable.""" 79 | body = b"" 80 | while True: 81 | message = await receive() 82 | if message["type"] == "http.request": 83 | body += message.get("body", b"") 84 | if not message.get("more_body", False): 85 | break 86 | return body 87 | 88 | async def _send_response_headers( 89 | self, send: Callable, response: aiohttp.ClientResponse 90 | ): 91 | """Send response headers to ASGI send callable.""" 92 | response_headers = [] 93 | for name, value in response.headers.items(): 94 | # Skip hop-by-hop headers 95 | if name.lower() not in self._hop_by_hop_headers: 96 | response_headers.append([name.encode("latin1"), value.encode("latin1")]) 97 | 98 | await send( 99 | { 100 | "type": "http.response.start", 101 | "status": response.status, 102 | "headers": response_headers, 103 | } 104 | ) 105 | 106 | async def _stream_response_body( 107 | self, send: Callable, response: aiohttp.ClientResponse 108 | ): 109 | """Stream response body from backend to client.""" 110 | async for chunk in response.content.iter_chunked(8192): 111 | await send( 112 | { 113 | "type": "http.response.body", 114 | "body": chunk, 115 | "more_body": True, 116 | } 117 | ) 118 | 119 | # Send final empty chunk 120 | await send( 121 | { 122 | "type": "http.response.body", 123 | "body": b"", 124 | "more_body": False, 125 | } 126 | ) 127 | 128 | async def _send_error_response(self, send: Callable, error: Exception): 129 | """Send a 502 error response for proxy errors.""" 130 | error_response = f'{{"error": "Proxy error: {str(error)}"}}'.encode() 131 | await send( 132 | { 133 | "type": "http.response.start", 134 | "status": 502, 135 | "headers": [[b"content-type", b"application/json"]], 136 | } 137 | ) 138 | await send( 139 | { 140 | "type": "http.response.body", 141 | "body": error_response, 142 | } 143 | ) 144 | 145 | async def forward_http_request( 146 | self, scope: dict, receive: Callable, send: Callable 147 | ): 148 | """Forward HTTP request to backend server.""" 149 | server_url = self.lb.choose() 150 | 151 | # Build target URL 152 | path = scope.get("path", "/") 153 | query_string = scope.get("query_string", b"").decode("utf-8") 154 | target_url = self._build_target_url(server_url, path, query_string) 155 | 156 | # Extract request details 157 | method = scope.get("method", "GET") 158 | headers = self._extract_headers(scope) 159 | body = await self._collect_request_body(receive) 160 | 161 | try: 162 | async with self._session.request( 163 | method=method, url=target_url, headers=headers, data=body 164 | ) as response: 165 | await self._send_response_headers(send, response) 166 | await self._stream_response_body(send, response) 167 | 168 | except Exception as e: 169 | await self._send_error_response(send, e) 170 | 171 | async def forward_websocket_connection( 172 | self, scope: dict, receive: Callable, send: Callable 173 | ): 174 | """Forward WebSocket connection to backend server.""" 175 | server_url = self.lb.choose() 176 | 177 | # Build WebSocket target URL 178 | path = scope.get("path", "/") 179 | query_string = scope.get("query_string", b"").decode("utf-8") 180 | ws_url = server_url.replace("http://", "ws://").replace("https://", "wss://") 181 | target_url = self._build_target_url(ws_url, path, query_string) 182 | 183 | # Extract headers 184 | headers = self._extract_headers(scope) 185 | 186 | try: 187 | async with self._session.ws_connect(target_url, headers=headers) as ws: 188 | # Send WebSocket accept 189 | await send({"type": "websocket.accept"}) 190 | 191 | async def forward_from_client(): 192 | """Forward messages from client to server.""" 193 | while True: 194 | message = await receive() 195 | if message["type"] == "websocket.receive": 196 | if "bytes" in message: 197 | await ws.send_bytes(message["bytes"]) 198 | elif "text" in message: 199 | await ws.send_str(message["text"]) 200 | elif message["type"] == "websocket.disconnect": 201 | await ws.close() 202 | break 203 | 204 | async def forward_from_server(): 205 | """Forward messages from server to client.""" 206 | async for msg in ws: 207 | if msg.type == aiohttp.WSMsgType.TEXT: 208 | await send({"type": "websocket.send", "text": msg.data}) 209 | elif msg.type == aiohttp.WSMsgType.BINARY: 210 | await send({"type": "websocket.send", "bytes": msg.data}) 211 | elif msg.type == aiohttp.WSMsgType.ERROR: 212 | break 213 | 214 | # Run both forwarding tasks concurrently 215 | await asyncio.gather( 216 | forward_from_client(), forward_from_server(), return_exceptions=True 217 | ) 218 | 219 | except Exception: 220 | await send( 221 | {"type": "websocket.close", "code": 1011, "reason": b"Proxy error"} 222 | ) 223 | -------------------------------------------------------------------------------- /tests/test_gateway_servers_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import patch, AsyncMock 3 | 4 | from premier.asgi import ( 5 | ASGIGateway, 6 | CircuitBreakerConfig, 7 | FeatureConfig, 8 | GatewayConfig, 9 | PathConfig, 10 | create_gateway, 11 | ) 12 | 13 | 14 | class TestGatewayServersConfig: 15 | """Test servers configuration in Gateway.""" 16 | 17 | def test_gateway_config_with_servers(self): 18 | """Test GatewayConfig with servers field.""" 19 | servers = ["http://server1.com", "http://server2.com"] 20 | config = GatewayConfig( 21 | paths=[], 22 | servers=servers, 23 | ) 24 | 25 | assert config.servers == servers 26 | 27 | def test_gateway_config_without_servers(self): 28 | """Test GatewayConfig defaults to None for servers.""" 29 | config = GatewayConfig(paths=[]) 30 | assert config.servers is None 31 | 32 | def test_gateway_init_uses_config_servers(self): 33 | """Test Gateway uses servers from config when not provided in constructor.""" 34 | servers = ["http://server1.com", "http://server2.com"] 35 | config = GatewayConfig(paths=[], servers=servers) 36 | 37 | with pytest.raises(RuntimeError, match="aiohttp is required"): 38 | # This will fail due to missing aiohttp, but we can still test the logic 39 | gateway = ASGIGateway(config=config) 40 | 41 | def test_gateway_init_constructor_servers_override_config(self): 42 | """Test constructor servers parameter overrides config servers.""" 43 | config_servers = ["http://config-server.com"] 44 | constructor_servers = ["http://constructor-server.com"] 45 | 46 | config = GatewayConfig(paths=[], servers=config_servers) 47 | 48 | with pytest.raises(RuntimeError, match="aiohttp is required"): 49 | gateway = ASGIGateway(config=config, servers=constructor_servers) 50 | 51 | def test_gateway_init_no_servers_no_forward_service(self): 52 | """Test Gateway doesn't initialize forward service when no servers.""" 53 | config = GatewayConfig(paths=[]) 54 | gateway = ASGIGateway(config=config) 55 | 56 | assert gateway.servers is None 57 | assert gateway._forward_service is None 58 | 59 | def test_gateway_init_with_config_servers_requires_aiohttp(self): 60 | """Test Gateway with config servers requires aiohttp.""" 61 | servers = ["http://server1.com", "http://server2.com"] 62 | config = GatewayConfig(paths=[], servers=servers) 63 | 64 | # Should raise RuntimeError because aiohttp is not available 65 | with pytest.raises(RuntimeError, match="aiohttp is required"): 66 | gateway = ASGIGateway(config=config) 67 | 68 | def test_gateway_config_from_dict_includes_servers(self): 69 | """Test GatewayConfig._from_dict includes servers.""" 70 | data = { 71 | "paths": [], 72 | "servers": ["http://server1.com", "http://server2.com"], 73 | "keyspace": "test-gateway" 74 | } 75 | 76 | config = GatewayConfig._from_dict(data) 77 | 78 | assert config.servers == ["http://server1.com", "http://server2.com"] 79 | assert config.keyspace == "test-gateway" 80 | 81 | def test_gateway_config_from_dict_without_servers(self): 82 | """Test GatewayConfig._from_dict defaults servers to None.""" 83 | data = { 84 | "paths": [], 85 | "keyspace": "test-gateway" 86 | } 87 | 88 | config = GatewayConfig._from_dict(data) 89 | 90 | assert config.servers is None 91 | assert config.keyspace == "test-gateway" 92 | 93 | 94 | class TestCircuitBreakerConfig: 95 | """Test circuit breaker configuration.""" 96 | 97 | def test_circuit_breaker_config_defaults(self): 98 | """Test CircuitBreakerConfig default values.""" 99 | config = CircuitBreakerConfig() 100 | 101 | assert config.failure_threshold == 5 102 | assert config.recovery_timeout == 60.0 103 | assert config.expected_exception == Exception 104 | 105 | def test_circuit_breaker_config_custom_values(self): 106 | """Test CircuitBreakerConfig with custom values.""" 107 | config = CircuitBreakerConfig( 108 | failure_threshold=3, 109 | recovery_timeout=30.0, 110 | expected_exception=ValueError, 111 | ) 112 | 113 | assert config.failure_threshold == 3 114 | assert config.recovery_timeout == 30.0 115 | assert config.expected_exception == ValueError 116 | 117 | def test_feature_config_with_circuit_breaker(self): 118 | """Test FeatureConfig includes circuit breaker.""" 119 | cb_config = CircuitBreakerConfig(failure_threshold=3) 120 | feature_config = FeatureConfig(circuit_breaker=cb_config) 121 | 122 | assert feature_config.circuit_breaker == cb_config 123 | assert feature_config.circuit_breaker_instance is None # Not compiled yet 124 | 125 | 126 | 127 | def test_gateway_config_parse_features_circuit_breaker(self): 128 | """Test _parse_features parses circuit breaker config.""" 129 | features_data = { 130 | "circuit_breaker": { 131 | "failure_threshold": 3, 132 | "recovery_timeout": 30.0, 133 | } 134 | } 135 | 136 | feature_config = GatewayConfig._parse_features(features_data) 137 | 138 | assert feature_config.circuit_breaker is not None 139 | assert feature_config.circuit_breaker.failure_threshold == 3 140 | assert feature_config.circuit_breaker.recovery_timeout == 30.0 141 | assert feature_config.circuit_breaker.expected_exception == Exception 142 | 143 | def test_gateway_config_parse_features_circuit_breaker_defaults(self): 144 | """Test _parse_features uses defaults for missing circuit breaker values.""" 145 | features_data = { 146 | "circuit_breaker": {} 147 | } 148 | 149 | feature_config = GatewayConfig._parse_features(features_data) 150 | 151 | assert feature_config.circuit_breaker is not None 152 | assert feature_config.circuit_breaker.failure_threshold == 5 153 | assert feature_config.circuit_breaker.recovery_timeout == 60.0 154 | 155 | def test_gateway_compile_features_creates_circuit_breaker_instance(self): 156 | """Test _compile_features creates circuit breaker instance.""" 157 | cb_config = CircuitBreakerConfig(failure_threshold=3) 158 | feature_config = FeatureConfig(circuit_breaker=cb_config) 159 | 160 | config = GatewayConfig(paths=[]) 161 | gateway = ASGIGateway(config=config) 162 | 163 | compiled_features = gateway._compile_features(feature_config) 164 | 165 | assert compiled_features.circuit_breaker_instance is not None 166 | assert compiled_features.circuit_breaker_instance.failure_threshold == 3 167 | 168 | @pytest.mark.asyncio 169 | async def test_gateway_circuit_breaker_integration(self): 170 | """Test circuit breaker integration in gateway handler chain.""" 171 | cb_config = CircuitBreakerConfig(failure_threshold=2, recovery_timeout=0.1) 172 | feature_config = FeatureConfig(circuit_breaker=cb_config) 173 | path_config = PathConfig(pattern="/test", features=feature_config) 174 | gateway_config = GatewayConfig(paths=[path_config]) 175 | 176 | failure_count = 0 177 | 178 | async def failing_app(scope, receive, send): 179 | nonlocal failure_count 180 | failure_count += 1 181 | if failure_count <= 2: 182 | raise Exception("Test failure") 183 | 184 | await send({ 185 | "type": "http.response.start", 186 | "status": 200, 187 | "headers": [[b"content-type", b"text/plain"]], 188 | }) 189 | await send({ 190 | "type": "http.response.body", 191 | "body": b"Success after recovery", 192 | }) 193 | 194 | gateway = ASGIGateway(config=gateway_config, app=failing_app) 195 | 196 | scope = {"type": "http", "method": "GET", "path": "/test"} 197 | receive = AsyncMock(return_value={"type": "http.request", "body": b"", "more_body": False}) 198 | send = AsyncMock() 199 | 200 | # First two calls should fail and open the circuit 201 | with pytest.raises(Exception, match="Test failure"): 202 | await gateway(scope, receive, send) 203 | 204 | with pytest.raises(Exception, match="Test failure"): 205 | await gateway(scope, receive, send) 206 | 207 | # Third call should be blocked by circuit breaker 208 | from premier.features.retry import CircuitBreakerOpenException 209 | with pytest.raises(CircuitBreakerOpenException): 210 | await gateway(scope, receive, send) 211 | 212 | 213 | class TestFactoryFunctionWithServers: 214 | """Test factory function with servers configuration.""" 215 | 216 | def test_create_gateway_with_config_servers(self): 217 | """Test create_gateway uses servers from config.""" 218 | servers = ["http://server1.com"] 219 | config = GatewayConfig(paths=[], servers=servers) 220 | 221 | with pytest.raises(RuntimeError, match="aiohttp is required"): 222 | gateway = create_gateway(config) -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration Reference 2 | 3 | Premier supports extensive configuration options for path-based policies. This page provides a complete reference of all available configuration fields. 4 | 5 | ## YAML Configuration Overview 6 | 7 | Premier uses YAML files for declarative configuration. Here's the basic structure: 8 | 9 | ```yaml 10 | premier: 11 | keyspace: "my-api" # Namespace for cache keys and throttling 12 | servers: [] # Backend servers (standalone mode) 13 | paths: [] # Path-specific configuration rules 14 | default_features: {} # Default features applied to all paths 15 | ``` 16 | 17 | ## Top-Level Configuration 18 | 19 | | Field | Type | Description | Default | 20 | |-------|------|-------------|---------| 21 | | `keyspace` | string | Namespace for cache keys and throttling | `"asgi-gateway"` | 22 | | `paths` | array | Path-specific configuration rules | `[]` | 23 | | `default_features` | object | Default features applied to all paths | `null` | 24 | | `servers` | array | Backend server URLs for standalone mode | `null` | 25 | 26 | ### Example 27 | 28 | ```yaml 29 | premier: 30 | keyspace: "production-api" 31 | servers: 32 | - "http://backend1:8000" 33 | - "http://backend2:8000" 34 | 35 | default_features: 36 | timeout: 37 | seconds: 10.0 38 | monitoring: 39 | log_threshold: 1.0 40 | ``` 41 | 42 | ## Path Configuration 43 | 44 | Configure features for specific URL patterns using path rules. 45 | 46 | | Field | Type | Description | Example | 47 | |-------|------|-------------|---------| 48 | | `pattern` | string | Path pattern (regex or glob-style) | `"/api/users/*"`, `"^/admin/.*$"` | 49 | | `features` | object | Features to apply to this path | See feature configuration below | 50 | 51 | ### Pattern Matching 52 | 53 | Premier supports both glob-style and regex patterns: 54 | 55 | === "Glob Style" 56 | ```yaml 57 | paths: 58 | - pattern: "/api/users/*" # Matches /api/users/123 59 | - pattern: "/api/*/posts" # Matches /api/v1/posts 60 | - pattern: "/files/**" # Matches /files/images/photo.jpg 61 | ``` 62 | 63 | === "Regex Style" 64 | ```yaml 65 | paths: 66 | - pattern: "^/api/users/\\d+$" # Matches /api/users/123 (numbers only) 67 | - pattern: "^/admin/.*$" # Matches any path starting with /admin/ 68 | - pattern: "\\.json$" # Matches paths ending with .json 69 | ``` 70 | 71 | ## Feature Configuration 72 | 73 | ### Cache Configuration 74 | 75 | Enable response caching for improved performance. 76 | 77 | | Field | Type | Description | Default | Example | 78 | |-------|------|-------------|---------|---------| 79 | | `expire_s` | integer | Cache expiration in seconds | `null` (no expiration) | `300` | 80 | | `cache_key` | string/function | Custom cache key | Auto-generated | `"user:{user_id}"` | 81 | 82 | ```yaml 83 | cache: 84 | expire_s: 300 # Cache for 5 minutes 85 | cache_key: "custom_key" # Optional custom key 86 | ``` 87 | 88 | ### Rate Limiting Configuration 89 | 90 | Control request rates to prevent abuse and ensure fair usage. 91 | 92 | | Field | Type | Description | Default | Example | 93 | |-------|------|-------------|---------|---------| 94 | | `quota` | integer | Number of requests allowed | Required | `100` | 95 | | `duration` | integer | Time window in seconds | Required | `60` | 96 | | `algorithm` | string | Rate limiting algorithm | `"fixed_window"` | `"sliding_window"` | 97 | | `bucket_size` | integer | Bucket size (for leaky_bucket) | Same as quota | `50` | 98 | | `error_status` | integer | HTTP status code for rate limit errors | `429` | `503` | 99 | | `error_message` | string | Error message for rate limit errors | `"Rate limit exceeded"` | `"Too many requests"` | 100 | 101 | #### Available Algorithms 102 | 103 | - **`fixed_window`**: Simple time-based windows 104 | - **`sliding_window`**: Smooth rate limiting over time 105 | - **`token_bucket`**: Burst capacity with steady refill rate 106 | - **`leaky_bucket`**: Queue-based rate limiting with controlled draining 107 | 108 | ```yaml 109 | rate_limit: 110 | quota: 100 111 | duration: 60 112 | algorithm: "sliding_window" 113 | error_status: 429 114 | error_message: "Rate limit exceeded for this endpoint" 115 | ``` 116 | 117 | ### Timeout Configuration 118 | 119 | Set maximum response times to prevent hanging requests. 120 | 121 | | Field | Type | Description | Default | Example | 122 | |-------|------|-------------|---------|---------| 123 | | `seconds` | float | Timeout duration in seconds | Required | `5.0` | 124 | | `error_status` | integer | HTTP status code for timeout errors | `504` | `408` | 125 | | `error_message` | string | Error message for timeout errors | `"Request timeout"` | `"Request took too long"` | 126 | 127 | ```yaml 128 | timeout: 129 | seconds: 5.0 130 | error_status: 504 131 | error_message: "Request timeout - please try again" 132 | ``` 133 | 134 | ### Retry Configuration 135 | 136 | Automatically retry failed requests with configurable backoff strategies. 137 | 138 | | Field | Type | Description | Default | Example | 139 | |-------|------|-------------|---------|---------| 140 | | `max_attempts` | integer | Maximum retry attempts | `3` | `5` | 141 | | `wait` | float/array/function | Wait time between retries | `1.0` | `[1, 2, 4]` | 142 | | `exceptions` | array | Exception types to retry on | `[Exception]` | Custom exceptions | 143 | 144 | #### Wait Strategies 145 | 146 | === "Fixed Delay" 147 | ```yaml 148 | retry: 149 | max_attempts: 3 150 | wait: 1.0 # Wait 1 second between retries 151 | ``` 152 | 153 | === "Exponential Backoff" 154 | ```yaml 155 | retry: 156 | max_attempts: 4 157 | wait: [1, 2, 4, 8] # Increasing delays 158 | ``` 159 | 160 | ### Circuit Breaker Configuration 161 | 162 | Prevent cascading failures by temporarily disabling failing services. 163 | 164 | | Field | Type | Description | Default | Example | 165 | |-------|------|-------------|---------|---------| 166 | | `failure_threshold` | integer | Failures before opening circuit | `5` | `10` | 167 | | `recovery_timeout` | float | Seconds before attempting recovery | `60.0` | `120.0` | 168 | | `expected_exception` | string | Exception type that triggers circuit | `"Exception"` | `"ConnectionError"` | 169 | 170 | ```yaml 171 | circuit_breaker: 172 | failure_threshold: 5 173 | recovery_timeout: 60.0 174 | expected_exception: "ConnectionError" 175 | ``` 176 | 177 | ### Monitoring Configuration 178 | 179 | Configure performance monitoring and logging thresholds. 180 | 181 | | Field | Type | Description | Default | Example | 182 | |-------|------|-------------|---------|---------| 183 | | `log_threshold` | float | Log requests taking longer than this (seconds) | `0.1` | `1.0` | 184 | 185 | ```yaml 186 | monitoring: 187 | log_threshold: 0.1 # Log requests > 100ms 188 | ``` 189 | 190 | ## Complete Configuration Example 191 | 192 | Here's a comprehensive example showing all features: 193 | 194 | ```yaml 195 | premier: 196 | keyspace: "production-api" 197 | servers: 198 | - "http://backend1:8000" 199 | - "http://backend2:8000" 200 | 201 | paths: 202 | # High-traffic user API with aggressive caching 203 | - pattern: "/api/users/*" 204 | features: 205 | cache: 206 | expire_s: 300 207 | rate_limit: 208 | quota: 1000 209 | duration: 60 210 | algorithm: "sliding_window" 211 | error_status: 429 212 | error_message: "Rate limit exceeded for user API" 213 | timeout: 214 | seconds: 5.0 215 | error_status: 504 216 | error_message: "User API timeout" 217 | retry: 218 | max_attempts: 3 219 | wait: [1, 2, 4] 220 | circuit_breaker: 221 | failure_threshold: 5 222 | recovery_timeout: 60.0 223 | monitoring: 224 | log_threshold: 0.1 225 | 226 | # Admin API with strict rate limiting 227 | - pattern: "/api/admin/*" 228 | features: 229 | rate_limit: 230 | quota: 10 231 | duration: 60 232 | algorithm: "token_bucket" 233 | error_status: 403 234 | error_message: "Admin API rate limit exceeded" 235 | timeout: 236 | seconds: 30.0 237 | error_status: 408 238 | error_message: "Admin operation timeout" 239 | monitoring: 240 | log_threshold: 0.5 241 | 242 | # WebSocket connections 243 | - pattern: "/ws/*" 244 | features: 245 | rate_limit: 246 | quota: 50 247 | duration: 60 248 | algorithm: "sliding_window" 249 | monitoring: 250 | log_threshold: 1.0 251 | 252 | # Applied to all paths that don't match above patterns 253 | default_features: 254 | timeout: 255 | seconds: 10.0 256 | rate_limit: 257 | quota: 100 258 | duration: 60 259 | algorithm: "fixed_window" 260 | monitoring: 261 | log_threshold: 1.0 262 | ``` 263 | 264 | ## Environment Variables 265 | 266 | You can override configuration values using environment variables: 267 | 268 | ```bash 269 | export PREMIER_KEYSPACE="production" 270 | export PREMIER_REDIS_URL="redis://redis:6379" 271 | export PREMIER_LOG_LEVEL="INFO" 272 | ``` 273 | 274 | ## Loading Configuration 275 | 276 | ### From File 277 | 278 | ```python 279 | from premier.asgi import GatewayConfig 280 | 281 | config = GatewayConfig.from_file("gateway.yaml") 282 | ``` 283 | 284 | ### From Dictionary 285 | 286 | ```python 287 | config_dict = { 288 | "premier": { 289 | "keyspace": "my-api", 290 | "paths": [...] 291 | } 292 | } 293 | config = GatewayConfig.from_dict(config_dict) 294 | ``` 295 | 296 | ### From Environment 297 | 298 | ```python 299 | # Loads from PREMIER_CONFIG_FILE environment variable 300 | config = GatewayConfig.from_env() 301 | ``` 302 | 303 | ## Configuration Validation 304 | 305 | Premier validates your configuration at startup and provides helpful error messages: 306 | 307 | ```python 308 | try: 309 | config = GatewayConfig.from_file("invalid.yaml") 310 | except ConfigurationError as e: 311 | print(f"Configuration error: {e}") 312 | ``` 313 | 314 | ## Hot Reloading 315 | 316 | Premier supports hot reloading of configuration in development mode: 317 | 318 | ```python 319 | config = GatewayConfig.from_file("gateway.yaml", watch=True) 320 | gateway = ASGIGateway(config, app=app) 321 | ``` 322 | 323 | Changes to the YAML file will be automatically detected and applied without restarting the application. 324 | 325 | ## Next Steps 326 | 327 | - [Web Dashboard](web-gui.md) - Monitor and edit configuration via web UI 328 | - [Examples](examples.md) - See complete configuration examples -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## version 0.4.10 (2025-06-25) 4 | 5 | ### Features 6 | 7 | - **🔧 Customizable Error Responses** - Added configurable error status codes and messages for ASGI Gateway features: 8 | - `TimeoutConfig` now supports `error_status` and `error_message` fields (defaults: 504, "Request timeout") 9 | - `RateLimitConfig` now supports `error_status` and `error_message` fields (defaults: 429, "Rate limit exceeded") 10 | - Users can customize error responses in YAML configuration files 11 | - Example: 12 | ```yaml 13 | timeout: 14 | seconds: 5.0 15 | error_status: 408 16 | error_message: "Custom timeout message" 17 | rate_limit: 18 | quota: 100 19 | duration: 60 20 | error_status: 503 21 | error_message: "Service temporarily unavailable" 22 | ``` 23 | 24 | ### Refactoring 25 | 26 | - **📦 Features Directory Restructuring** - Major code organization improvements: 27 | - Moved core features to dedicated `premier/features/` directory 28 | - `premier/cache.py` → `premier/features/cache.py` 29 | - `premier/retry.py` → `premier/features/retry.py` 30 | - `premier/timer.py` → `premier/features/timer.py` 31 | - `premier/throttler/` → `premier/features/throttler/` 32 | - Updated all imports and dependencies accordingly 33 | 34 | - **🛠️ Error Response Encapsulation** - Eliminated duplicate error response code: 35 | - Created reusable `send_error_response()` function in ASGI Gateway 36 | - Standardized error responses across timeout, rate limiting, and default handlers 37 | - Supports both JSON and plain text content types 38 | - Reduced code duplication and improved maintainability 39 | 40 | ### Technical Improvements 41 | 42 | - Enhanced ASGI Gateway architecture with better error handling patterns 43 | - Improved configuration parsing for new error response fields 44 | - All existing tests continue to pass with new functionality 45 | - Better separation of concerns in error response management 46 | 47 | ## version 0.4.9 (2025-06-14) 48 | 49 | ### Bug Fixes & Features 50 | 51 | - **Dashboard Stats Tracking** - Fixed dashboard statistics tracking issues and improved real-time data accuracy 52 | - **Dashboard Styling & Theme** - Enhanced dashboard visual appearance with improved styling and theme consistency 53 | - ️**Configurable Server List** – Users can now define and manage servers directly from a configuration file 54 | 55 | - **Load Balancing (Round Robin)** – Implemented round robin load balancer for distributing traffic evenly across servers 56 | 57 | - ️**Circuit Breaker Support** – Added circuit breaker mechanism to improve fault tolerance and system resilience 58 | 59 | ### Refactoring 60 | 61 | - **🔧 ASGI Architecture Refactor** - Major restructuring of ASGI components for better maintainability: 62 | - Separated forward service logic into dedicated module (`premier/asgi/forward.py`) 63 | - Created dedicated dashboard service (`premier/dashboard/service.py`) 64 | - Implemented load balancer component (`premier/asgi/loadbalancer.py`) 65 | - Simplified main gateway module by extracting specialized services 66 | - Improved code organization and separation of concerns 67 | 68 | 69 | 70 | ### Technical Improvements 71 | 72 | - Enhanced dashboard service architecture with better separation of concerns 73 | - Improved ASGI gateway performance through modular design 74 | - Better error handling and logging in dashboard components 75 | - Streamlined configuration management in dashboard 76 | 77 | ## version 0.4.8 (2025-06-14) 78 | 79 | ### Major Features 80 | 81 | - **🎛️ Web Dashboard** - Built-in web GUI for real-time monitoring and configuration management 82 | - Live request/response metrics and performance analytics 83 | - Interactive configuration editor with YAML validation 84 | - Cache management and rate limiting dashboard 85 | - Health monitoring and system statistics 86 | - Available at `/premier/dashboard` 87 | 88 | - **🚀 Complete Example Application** - Production-ready example with FastAPI backend 89 | - Comprehensive API endpoints demonstrating all Premier features 90 | - YAML configuration with path-specific policies 91 | - Documentation and testing guides 92 | - Dashboard integration showcase 93 | 94 | - **📚 Enhanced Documentation** - Comprehensive documentation overhaul 95 | - Separate guides for web dashboard and examples 96 | - Updated README with better organization 97 | - Clear quick-start instructions 98 | - Production deployment guidance 99 | 100 | ### New Files & Components 101 | 102 | - `premier/dashboard/` - Complete web dashboard implementation 103 | - `example/` - Full-featured example application 104 | - `docs/web-gui.md` - Web dashboard documentation 105 | - `docs/examples.md` - Examples and tutorials guide 106 | - Enhanced ASGI gateway with dashboard integration 107 | 108 | ### Improvements 109 | 110 | - **ASGI Gateway Enhancement** - Better integration and dashboard support 111 | - **Configuration Management** - Hot-reload configuration from web interface 112 | - **Monitoring** - Real-time performance metrics and request analytics 113 | - **User Experience** - Simplified setup with comprehensive examples 114 | 115 | ## v0.4.0 (2024-06-05) 116 | 117 | ### Chore 118 | 119 | * chore: fix test ([`17ce3e4`](https://github.com/raceychan/pythrottler/commit/17ce3e49e7efbe6f9b2e3faf864692b791cfe0df)) 120 | 121 | * chore: dev ([`22be11c`](https://github.com/raceychan/pythrottler/commit/22be11c00787ef1f7333202cc9bd2475c1a9bed6)) 122 | 123 | * chore: readme ([`7d5a5bc`](https://github.com/raceychan/pythrottler/commit/7d5a5bccde4c0b37e9a94693ca5885af3aa0246f)) 124 | 125 | * chore: readme ([`f376b5b`](https://github.com/raceychan/pythrottler/commit/f376b5bf9ca29ff81b3934662fe92d89560b4094)) 126 | 127 | * chore: readme ([`bdcb13f`](https://github.com/raceychan/pythrottler/commit/bdcb13fb5cce7b72660a0a6e89eabea684b54fc1)) 128 | 129 | * chore: readme ([`9d14e9e`](https://github.com/raceychan/pythrottler/commit/9d14e9e32b38f41722d7fa4b07fe89358d977b64)) 130 | 131 | ### Feature 132 | 133 | * feat: better error message ([`b1c2523`](https://github.com/raceychan/pythrottler/commit/b1c25239c837aa4a08915011ee89add4f64bb8e2)) 134 | 135 | * feat: async queue ([`d621219`](https://github.com/raceychan/pythrottler/commit/d6212191abedd331d255aea80592af644bde1fe8)) 136 | 137 | * feat: aio ([`67d85c2`](https://github.com/raceychan/pythrottler/commit/67d85c2b10858dc700c6f5f42d0fc3dd34518ba9)) 138 | 139 | * feat: aio ([`710e934`](https://github.com/raceychan/pythrottler/commit/710e934057361ec95e513a5ef8d2d96e590b491a)) 140 | 141 | ### Fix 142 | 143 | * fix: make TaskQueue.put atomic using redis lua script ([`69a49aa`](https://github.com/raceychan/pythrottler/commit/69a49aa502a4946f60179c1036717365bfc7e402)) 144 | 145 | * fix: fixed asyncio throttler using asyncio.Lock ([`4811312`](https://github.com/raceychan/pythrottler/commit/481131212897f998b09173cd6b4f63f6f02c5ce8)) 146 | 147 | * fix: fix typing ([`29f4a76`](https://github.com/raceychan/pythrottler/commit/29f4a76c07c10a0ede157f5566f15f2a0f64c643)) 148 | 149 | ### Refactor 150 | 151 | * refactor: rewrite leaky bucket ([`5e38981`](https://github.com/raceychan/pythrottler/commit/5e389812832af6ef054858bd8c1a449f8b821092)) 152 | 153 | ### Unknown 154 | 155 | * chores: fix conflicts ([`07198a8`](https://github.com/raceychan/pythrottler/commit/07198a8739845c081f5bd84fc9e002310ae89ea3)) 156 | 157 | * Merge branch 'dev' 158 | adding async throttler for async function, also fixes a few bug in 159 | threading case ([`630c8ed`](https://github.com/raceychan/pythrottler/commit/630c8ed6282a310c50fc26f57b42db2ff126bfb8)) 160 | 161 | * chores: last commit before merge ([`17110a9`](https://github.com/raceychan/pythrottler/commit/17110a92f57388369c3a2a6344051bde6aa99896)) 162 | 163 | * chores: fix type errors ([`0a69501`](https://github.com/raceychan/pythrottler/commit/0a6950191b67da0fa1a9c36a34e610cb2d0b4335)) 164 | 165 | * chores: refactor put script ([`fbca52e`](https://github.com/raceychan/pythrottler/commit/fbca52e190d02f369d74a64b8c6a6dc5132cb4e3)) 166 | 167 | * chores: fix typing ([`fccb598`](https://github.com/raceychan/pythrottler/commit/fccb598fb416d86119eed63d6508f8e971a6e161)) 168 | 169 | * wip: working on asyncio throttler ([`f933f10`](https://github.com/raceychan/pythrottler/commit/f933f10eb620a79b9801bd7cc7fb44657d759001)) 170 | 171 | * chores: remove setup.py ([`82b9ad6`](https://github.com/raceychan/pythrottler/commit/82b9ad612062a0d9755d61ddca4b34fbe23a8358)) 172 | 173 | * chores: test ([`6ff20d2`](https://github.com/raceychan/pythrottler/commit/6ff20d243a2f605fc250cf6926d90dd4d819393f)) 174 | 175 | 176 | ## v0.3.0 (2024-04-08) 177 | 178 | ### Chore 179 | 180 | * chore: readme ([`2429f50`](https://github.com/raceychan/pythrottler/commit/2429f5038b7bf241212821d1c9e7b9922b62b62a)) 181 | 182 | ### Feature 183 | 184 | * feat: refactor ([`e9f0c4d`](https://github.com/raceychan/pythrottler/commit/e9f0c4d45e9ff939e7c2942e03e50757f94e8333)) 185 | 186 | ### Fix 187 | 188 | * fix: minor errors ([`b8a630b`](https://github.com/raceychan/pythrottler/commit/b8a630bf59547bb76718dc378f0cdedecc507bc7)) 189 | 190 | ### Unknown 191 | 192 | * Merge pull request #1 from raceychan/dev 193 | 194 | merge latest dev branch ([`8523182`](https://github.com/raceychan/pythrottler/commit/852318253642684714edf7bd447316b4b0453ff4)) 195 | 196 | 197 | ## v0.2.0 (2024-04-03) 198 | 199 | ### Feature 200 | 201 | * feat: leakybucket ([`b0dde75`](https://github.com/raceychan/pythrottler/commit/b0dde755497574e729e8eceee582063290c2663c)) 202 | 203 | * feat: readme ([`4105d39`](https://github.com/raceychan/pythrottler/commit/4105d397ca789ce21f969c0504ed4e3a897e966a)) 204 | 205 | 206 | ## v0.1.0 (2024-04-02) 207 | 208 | ### Feature 209 | 210 | * feat: rename ([`8565565`](https://github.com/raceychan/pythrottler/commit/856556584df7c773d19bd74562621ec3f74579a9)) 211 | 212 | * feat: readme ([`1b9932a`](https://github.com/raceychan/pythrottler/commit/1b9932a6fe4a7b8249051fef7eff0678358774e3)) 213 | 214 | * feat: readme ([`bb93c35`](https://github.com/raceychan/pythrottler/commit/bb93c3576fc721b56041e1adad730698e822126d)) 215 | 216 | * feat: readme ([`d1e2b16`](https://github.com/raceychan/pythrottler/commit/d1e2b16d36180508c846ff5e81d9e1204af4f3b1)) 217 | 218 | * feat: readme ([`3f406db`](https://github.com/raceychan/pythrottler/commit/3f406db6fb0b2820c2c1d4b60b2843417e4164a1)) 219 | 220 | * feat: readme ([`a397853`](https://github.com/raceychan/pythrottler/commit/a397853e8a5354ffbe1e0a921d77be3b9aedefa2)) 221 | 222 | * feat: readme ([`8584238`](https://github.com/raceychan/pythrottler/commit/858423825bc49e1c54fde543d1a7c6c419307cf6)) 223 | 224 | * feat: first commit ([`ba6abfc`](https://github.com/raceychan/pythrottler/commit/ba6abfc3dfc474c4cf5eb1e8951563fce51f1a7a)) 225 | 226 | * feat: first commit ([`4a83c49`](https://github.com/raceychan/pythrottler/commit/4a83c499ea5ee0631e3667e10e2526407890f5c6)) 227 | 228 | * feat: first commit ([`7be616c`](https://github.com/raceychan/pythrottler/commit/7be616ca6200e8452d9eabebc93b0bbec01c1291)) 229 | 230 | 231 | ## version 0.4.3 232 | 233 | Feature 234 | 235 | - [x] `cache` 236 | 237 | 238 | refactor: 239 | 240 | No longer support sync version of decorator, which means all decorated function would be async. 241 | 242 | 243 | ## version 0.4.6 244 | 245 | - ✅ Implemented facade pattern with Premier class 246 | - ✅ Added comprehensive logging support with ILogger interface 247 | - ✅ Enhanced retry logic with detailed logging 248 | - ✅ Improved timeout handling with logging 249 | - ✅ Updated documentation and examples 250 | - ✅ Removed legacy task queue implementation 251 | - ✅ Made private functions properly private with underscore prefix 252 | 253 | ## version 0.4.7 254 | 255 | 256 | Web GUI for config and monitor --------------------------------------------------------------------------------