update fastapi-420 pypi
This commit is contained in:
parent
cb351c2c65
commit
44ca91cdf7
|
|
@ -0,0 +1,69 @@
|
||||||
|
# =============================================================================
|
||||||
|
# AngelaMos | 2026
|
||||||
|
# publish-api-rate-limiter.yml
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
name: Publish api-rate-limiter to PyPI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'PROJECTS/advanced/api-rate-limiter/**'
|
||||||
|
- '!PROJECTS/advanced/api-rate-limiter/README.md'
|
||||||
|
- '!PROJECTS/advanced/api-rate-limiter/justfile'
|
||||||
|
- '!PROJECTS/advanced/api-rate-limiter/learn/**'
|
||||||
|
- '!PROJECTS/advanced/api-rate-limiter/examples/**'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pypi-publish:
|
||||||
|
name: Upload api-rate-limiter to PyPI
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: pypi
|
||||||
|
url: https://pypi.org/p/fastapi-420
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.14"
|
||||||
|
|
||||||
|
- name: Check if version exists on PyPI
|
||||||
|
id: version_check
|
||||||
|
working-directory: PROJECTS/advanced/api-rate-limiter
|
||||||
|
run: |
|
||||||
|
VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/fastapi-420/${VERSION}/json")
|
||||||
|
if [ "$STATUS" = "200" ]; then
|
||||||
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::fastapi-420 v${VERSION} already on PyPI — skipping publish"
|
||||||
|
else
|
||||||
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::fastapi-420 v${VERSION} not on PyPI — publishing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install build dependencies
|
||||||
|
if: steps.version_check.outputs.exists != 'true'
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install build
|
||||||
|
|
||||||
|
- name: Build package
|
||||||
|
if: steps.version_check.outputs.exists != 'true'
|
||||||
|
working-directory: PROJECTS/advanced/api-rate-limiter
|
||||||
|
run: python -m build
|
||||||
|
|
||||||
|
- name: Publish to PyPI
|
||||||
|
if: steps.version_check.outputs.exists != 'true'
|
||||||
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
|
with:
|
||||||
|
packages-dir: PROJECTS/advanced/api-rate-limiter/dist/
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
# =============================================================================
|
|
||||||
# AngelaMos | 2025
|
|
||||||
# publish-api-rate-limter.yml
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
name: Publish api-rate-limiter to PyPI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- 'PROJECTS/advanced/api-rate-limiter/**'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
pypi-publish:
|
|
||||||
name: Upload api-rate-limiter to PyPI
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment:
|
|
||||||
name: pypi
|
|
||||||
url: https://pypi.org/p/fastapi-420
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.13"
|
|
||||||
|
|
||||||
- name: Install build dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install build
|
|
||||||
|
|
||||||
- name: Build package
|
|
||||||
working-directory: PROJECTS/advanced/api-rate-limiter
|
|
||||||
run: python -m build
|
|
||||||
|
|
||||||
- name: Publish to PyPI
|
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
|
||||||
with:
|
|
||||||
packages-dir: PROJECTS/advanced/api-rate-limiter/dist/
|
|
||||||
|
|
@ -35,16 +35,33 @@ jobs:
|
||||||
with:
|
with:
|
||||||
python-version: "3.14"
|
python-version: "3.14"
|
||||||
|
|
||||||
|
- name: Check if version exists on PyPI
|
||||||
|
id: version_check
|
||||||
|
working-directory: PROJECTS/beginner/base64-tool
|
||||||
|
run: |
|
||||||
|
VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/b64tool/${VERSION}/json")
|
||||||
|
if [ "$STATUS" = "200" ]; then
|
||||||
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::b64tool v${VERSION} already on PyPI — skipping publish"
|
||||||
|
else
|
||||||
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::b64tool v${VERSION} not on PyPI — publishing"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
|
if: steps.version_check.outputs.exists != 'true'
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install build
|
pip install build
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
|
if: steps.version_check.outputs.exists != 'true'
|
||||||
working-directory: PROJECTS/beginner/base64-tool
|
working-directory: PROJECTS/beginner/base64-tool
|
||||||
run: python -m build
|
run: python -m build
|
||||||
|
|
||||||
- name: Publish to PyPI
|
- name: Publish to PyPI
|
||||||
|
if: steps.version_check.outputs.exists != 'true'
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
with:
|
with:
|
||||||
packages-dir: PROJECTS/beginner/base64-tool/dist/
|
packages-dir: PROJECTS/beginner/base64-tool/dist/
|
||||||
|
|
|
||||||
|
|
@ -35,16 +35,33 @@ jobs:
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Check if version exists on PyPI
|
||||||
|
id: version_check
|
||||||
|
working-directory: PROJECTS/beginner/caesar-cipher
|
||||||
|
run: |
|
||||||
|
VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/caesar-salad-cipher/${VERSION}/json")
|
||||||
|
if [ "$STATUS" = "200" ]; then
|
||||||
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::caesar-salad-cipher v${VERSION} already on PyPI — skipping publish"
|
||||||
|
else
|
||||||
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::caesar-salad-cipher v${VERSION} not on PyPI — publishing"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
|
if: steps.version_check.outputs.exists != 'true'
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install build
|
pip install build
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
|
if: steps.version_check.outputs.exists != 'true'
|
||||||
working-directory: PROJECTS/beginner/caesar-cipher
|
working-directory: PROJECTS/beginner/caesar-cipher
|
||||||
run: python -m build
|
run: python -m build
|
||||||
|
|
||||||
- name: Publish to PyPI
|
- name: Publish to PyPI
|
||||||
|
if: steps.version_check.outputs.exists != 'true'
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
with:
|
with:
|
||||||
packages-dir: PROJECTS/beginner/caesar-cipher/dist/
|
packages-dir: PROJECTS/beginner/caesar-cipher/dist/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# AngelaMos | 2025
|
# AngelaMos | 2026
|
||||||
# publish-dns-lookup.yml
|
# publish-dns-lookup.yml
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
@ -35,16 +35,33 @@ jobs:
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
|
|
||||||
|
- name: Check if version exists on PyPI
|
||||||
|
id: version_check
|
||||||
|
working-directory: PROJECTS/beginner/dns-lookup
|
||||||
|
run: |
|
||||||
|
VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/dnslookup-cli/${VERSION}/json")
|
||||||
|
if [ "$STATUS" = "200" ]; then
|
||||||
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::dnslookup-cli v${VERSION} already on PyPI — skipping publish"
|
||||||
|
else
|
||||||
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::dnslookup-cli v${VERSION} not on PyPI — publishing"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
|
if: steps.version_check.outputs.exists != 'true'
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install build
|
pip install build
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
|
if: steps.version_check.outputs.exists != 'true'
|
||||||
working-directory: PROJECTS/beginner/dns-lookup
|
working-directory: PROJECTS/beginner/dns-lookup
|
||||||
run: python -m build
|
run: python -m build
|
||||||
|
|
||||||
- name: Publish to PyPI
|
- name: Publish to PyPI
|
||||||
|
if: steps.version_check.outputs.exists != 'true'
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
with:
|
with:
|
||||||
packages-dir: PROJECTS/beginner/dns-lookup/dist/
|
packages-dir: PROJECTS/beginner/dns-lookup/dist/
|
||||||
|
|
|
||||||
|
|
@ -35,16 +35,33 @@ jobs:
|
||||||
with:
|
with:
|
||||||
python-version: "3.14"
|
python-version: "3.14"
|
||||||
|
|
||||||
|
- name: Check if version exists on PyPI
|
||||||
|
id: version_check
|
||||||
|
working-directory: PROJECTS/beginner/network-traffic-analyzer
|
||||||
|
run: |
|
||||||
|
VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/netanal/${VERSION}/json")
|
||||||
|
if [ "$STATUS" = "200" ]; then
|
||||||
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::netanal v${VERSION} already on PyPI — skipping publish"
|
||||||
|
else
|
||||||
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::netanal v${VERSION} not on PyPI — publishing"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
|
if: steps.version_check.outputs.exists != 'true'
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install build
|
pip install build
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
|
if: steps.version_check.outputs.exists != 'true'
|
||||||
working-directory: PROJECTS/beginner/network-traffic-analyzer
|
working-directory: PROJECTS/beginner/network-traffic-analyzer
|
||||||
run: python -m build
|
run: python -m build
|
||||||
|
|
||||||
- name: Publish to PyPI
|
- name: Publish to PyPI
|
||||||
|
if: steps.version_check.outputs.exists != 'true'
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
with:
|
with:
|
||||||
packages-dir: PROJECTS/beginner/network-traffic-analyzer/dist/
|
packages-dir: PROJECTS/beginner/network-traffic-analyzer/dist/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,332 @@
|
||||||
|
<!--
|
||||||
|
ⒸAngelaMos | 2026
|
||||||
|
USAGE.md
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Using fastapi-420 as a Library
|
||||||
|
|
||||||
|
Reference guide for integrating `fastapi-420` into your own FastAPI project.
|
||||||
|
For security theory and architecture deep-dives, see the [learn modules](../learn/).
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add fastapi-420
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Python 3.12+. Dependencies (`fastapi`, `pydantic`, `pydantic-settings`, `redis`, `pyjwt`) are pulled in automatically.
|
||||||
|
|
||||||
|
## Minimal Setup
|
||||||
|
|
||||||
|
```python
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi_420 import RateLimiter, RateLimiterSettings, set_global_limiter
|
||||||
|
|
||||||
|
settings = RateLimiterSettings()
|
||||||
|
limiter = RateLimiter(settings=settings)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await limiter.init()
|
||||||
|
set_global_limiter(limiter)
|
||||||
|
yield
|
||||||
|
await limiter.close()
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
@app.get("/items")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def list_items(request: Request):
|
||||||
|
return {"items": []}
|
||||||
|
```
|
||||||
|
|
||||||
|
`set_global_limiter` registers the instance so dependency injection (`RateLimitDep`, `ScopedRateLimiter`) can find it without passing the limiter around manually.
|
||||||
|
|
||||||
|
## Three Integration Patterns
|
||||||
|
|
||||||
|
### 1. Middleware (Global)
|
||||||
|
|
||||||
|
Applies a blanket limit to every route. Health/metrics endpoints are excluded by default.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_420 import RateLimiter, RateLimiterSettings
|
||||||
|
from fastapi_420.middleware import RateLimitMiddleware
|
||||||
|
|
||||||
|
limiter = RateLimiter(RateLimiterSettings())
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
RateLimitMiddleware,
|
||||||
|
limiter=limiter,
|
||||||
|
default_limit="200/minute",
|
||||||
|
exclude_paths=["/internal/debug"],
|
||||||
|
exclude_patterns=[r"^/admin/.*"],
|
||||||
|
path_limits={
|
||||||
|
"/api/upload": "10/minute",
|
||||||
|
"/auth/login": "5/minute",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
There is also `SlowDownMiddleware` which adds progressive delays instead of hard-blocking:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_420.middleware import SlowDownMiddleware
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
SlowDownMiddleware,
|
||||||
|
limiter=limiter,
|
||||||
|
threshold_limit="50/minute",
|
||||||
|
max_delay_seconds=5.0,
|
||||||
|
delay_increment=0.5,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Decorator (Per-Route)
|
||||||
|
|
||||||
|
Requires `request: Request` in the function signature so the limiter can extract client fingerprints.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.get("/search")
|
||||||
|
@limiter.limit("30/minute", "500/hour")
|
||||||
|
async def search(request: Request, q: str):
|
||||||
|
return {"results": [], "query": q}
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple rules stack. The most restrictive one that triggers wins.
|
||||||
|
|
||||||
|
### 3. Dependency Injection
|
||||||
|
|
||||||
|
**Inline with `RateLimitDep`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi_420 import RateLimitDep
|
||||||
|
|
||||||
|
@app.get("/settings", dependencies=[Depends(RateLimitDep("30/minute"))])
|
||||||
|
async def get_settings():
|
||||||
|
return {"theme": "dark"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access the result object:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi_420 import RateLimitDep, RateLimitResult
|
||||||
|
|
||||||
|
@app.get("/data")
|
||||||
|
async def get_data(
|
||||||
|
result: Annotated[RateLimitResult, Depends(RateLimitDep("100/minute"))],
|
||||||
|
):
|
||||||
|
return {"remaining": result.remaining, "reset_in": result.reset_after}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Default limits with `require_rate_limit`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_420 import require_rate_limit
|
||||||
|
|
||||||
|
@app.get("/default-limited")
|
||||||
|
async def default_limited(
|
||||||
|
result: Annotated[RateLimitResult, Depends(require_rate_limit)],
|
||||||
|
):
|
||||||
|
return {"remaining": result.remaining}
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses whatever `DEFAULT_LIMITS` is set to in your `RateLimiterSettings`.
|
||||||
|
|
||||||
|
## Scoped Rate Limiters
|
||||||
|
|
||||||
|
Group endpoints under a shared limiter with per-endpoint overrides.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_420 import ScopedRateLimiter
|
||||||
|
|
||||||
|
auth_limiter = ScopedRateLimiter(
|
||||||
|
prefix="/auth",
|
||||||
|
default_rules=["5/minute", "20/hour"],
|
||||||
|
endpoint_rules={
|
||||||
|
"POST:/auth/login": ["3/minute", "10/hour"],
|
||||||
|
"POST:/auth/register": ["2/minute", "5/hour"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/auth/login", dependencies=[Depends(auth_limiter)])
|
||||||
|
async def login(username: str, password: str):
|
||||||
|
return {"token": "..."}
|
||||||
|
|
||||||
|
@app.post("/auth/register", dependencies=[Depends(auth_limiter)])
|
||||||
|
async def register(username: str, password: str):
|
||||||
|
return {"user_id": 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoint rule keys use the format `METHOD:/path`. If no specific rule matches, `default_rules` applies.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### RateLimiterSettings
|
||||||
|
|
||||||
|
All settings are [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) and can be set via environment variables with the `RATELIMIT_` prefix.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_420 import (
|
||||||
|
RateLimiterSettings,
|
||||||
|
StorageSettings,
|
||||||
|
FingerprintSettings,
|
||||||
|
)
|
||||||
|
from fastapi_420.types import Algorithm, FingerprintLevel
|
||||||
|
|
||||||
|
settings = RateLimiterSettings(
|
||||||
|
ENABLED=True,
|
||||||
|
ALGORITHM=Algorithm.SLIDING_WINDOW,
|
||||||
|
DEFAULT_LIMIT="100/minute",
|
||||||
|
DEFAULT_LIMITS=["100/minute", "1000/hour"],
|
||||||
|
FAIL_OPEN=True,
|
||||||
|
KEY_PREFIX="myapp",
|
||||||
|
INCLUDE_HEADERS=True,
|
||||||
|
LOG_VIOLATIONS=True,
|
||||||
|
ENVIRONMENT="production",
|
||||||
|
storage=StorageSettings(
|
||||||
|
REDIS_URL="redis://localhost:6379/0",
|
||||||
|
REDIS_MAX_CONNECTIONS=100,
|
||||||
|
FALLBACK_TO_MEMORY=True,
|
||||||
|
MEMORY_MAX_KEYS=100_000,
|
||||||
|
),
|
||||||
|
fingerprint=FingerprintSettings(
|
||||||
|
LEVEL=FingerprintLevel.NORMAL,
|
||||||
|
TRUST_X_FORWARDED_FOR=True,
|
||||||
|
TRUSTED_PROXIES=["10.0.0.0/8"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Instead of passing values in code, set them in your environment or `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RATELIMIT_ENABLED=true
|
||||||
|
RATELIMIT_ALGORITHM=sliding_window
|
||||||
|
RATELIMIT_DEFAULT_LIMIT=100/minute
|
||||||
|
RATELIMIT_KEY_PREFIX=myapp
|
||||||
|
RATELIMIT_FAIL_OPEN=true
|
||||||
|
RATELIMIT_ENVIRONMENT=production
|
||||||
|
|
||||||
|
RATELIMIT_REDIS_URL=redis://localhost:6379/0
|
||||||
|
RATELIMIT_REDIS_MAX_CONNECTIONS=100
|
||||||
|
RATELIMIT_FALLBACK_TO_MEMORY=true
|
||||||
|
|
||||||
|
RATELIMIT_FP_LEVEL=normal
|
||||||
|
RATELIMIT_FP_TRUST_X_FORWARDED_FOR=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Then just use `RateLimiterSettings()` with no arguments and it picks up everything from the environment.
|
||||||
|
|
||||||
|
### Algorithms
|
||||||
|
|
||||||
|
| Algorithm | Best For | Trade-off |
|
||||||
|
|-----------|----------|-----------|
|
||||||
|
| `SLIDING_WINDOW` | General use (default) | 99.997% accurate, slightly more memory |
|
||||||
|
| `TOKEN_BUCKET` | Burst-tolerant APIs | Allows short bursts up to capacity |
|
||||||
|
| `FIXED_WINDOW` | Simple counting | Boundary burst problem at window edges |
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_420.types import Algorithm
|
||||||
|
|
||||||
|
settings = RateLimiterSettings(ALGORITHM=Algorithm.TOKEN_BUCKET)
|
||||||
|
```
|
||||||
|
|
||||||
|
All three algorithms use atomic Lua scripts when backed by Redis, so they are safe under concurrent load.
|
||||||
|
|
||||||
|
### Fingerprint Levels
|
||||||
|
|
||||||
|
Controls how aggressively clients are identified:
|
||||||
|
|
||||||
|
| Level | Components | Use Case |
|
||||||
|
|-------|-----------|----------|
|
||||||
|
| `RELAXED` | IP + auth token (if present) | Public APIs, mobile apps |
|
||||||
|
| `NORMAL` | IP + User-Agent + auth token | General web applications |
|
||||||
|
| `STRICT` | IP + UA + Accept headers + header order + TLS + geo | Anti-abuse, financial APIs |
|
||||||
|
|
||||||
|
### Rate Limit Rule Format
|
||||||
|
|
||||||
|
Rules follow the pattern `count/unit`:
|
||||||
|
|
||||||
|
```
|
||||||
|
"100/minute" "1000/hour" "10000/day" "5/second"
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepted units: `second`, `seconds`, `sec`, `s`, `minute`, `minutes`, `min`, `m`, `hour`, `hours`, `hr`, `h`, `day`, `days`, `d`.
|
||||||
|
|
||||||
|
## Redis Setup
|
||||||
|
|
||||||
|
For production, run Redis alongside your app. The `examples/docker-compose.yml` in this directory provides a ready-to-use setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f examples/docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
If Redis is unavailable and `FALLBACK_TO_MEMORY=True` (default), the limiter automatically falls back to in-memory storage. If `FAIL_OPEN=True` (default), requests are allowed through when both storage backends fail.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
When a client exceeds their limit, the limiter raises `EnhanceYourCalm` (HTTP 420). The response looks like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Enhance your calm",
|
||||||
|
"detail": "Rate limit exceeded. Take a breather.",
|
||||||
|
"limit_info": {
|
||||||
|
"RateLimit-Limit": "100",
|
||||||
|
"RateLimit-Remaining": "0",
|
||||||
|
"RateLimit-Reset": "45",
|
||||||
|
"Retry-After": "45"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response headers (`RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `Retry-After`) follow the IETF draft standard and are included when `INCLUDE_HEADERS=True`.
|
||||||
|
|
||||||
|
To customize the rejection message:
|
||||||
|
|
||||||
|
```python
|
||||||
|
settings = RateLimiterSettings(
|
||||||
|
HTTP_420_MESSAGE="Slow down there",
|
||||||
|
HTTP_420_DETAIL="You've exceeded your rate limit. Wait and try again.",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Key Functions
|
||||||
|
|
||||||
|
Override the default fingerprinting with your own key extraction logic:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def key_by_api_key(request: Request) -> str:
|
||||||
|
return request.headers.get("X-API-Key", "anonymous")
|
||||||
|
|
||||||
|
@app.get("/partner/data")
|
||||||
|
@limiter.limit("1000/hour", key_func=key_by_api_key)
|
||||||
|
async def partner_data(request: Request):
|
||||||
|
return {"data": "..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
This also works with `RateLimitDep`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
dep = RateLimitDep("500/hour", key_func=key_by_api_key)
|
||||||
|
|
||||||
|
@app.get("/partner/info", dependencies=[Depends(dep)])
|
||||||
|
async def partner_info():
|
||||||
|
return {"info": "..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full Working Example
|
||||||
|
|
||||||
|
See [`app.py`](app.py) in this directory for a complete FastAPI application demonstrating all three integration patterns with tiered limits across auth, public, and user endpoint groups.
|
||||||
|
|
||||||
|
Run it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
uv run python examples/app.py
|
||||||
|
```
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
[project]
|
[project]
|
||||||
name = "fastapi-420"
|
name = "fastapi-420"
|
||||||
version = "0.1.0"
|
version = "1.0.1"
|
||||||
description = "Enhance Your Calm - Advanced Rate Limiting & DDoS Protection for FastAPI"
|
description = "Enhance Your Calm - Advanced Rate Limiting & DDoS Protection for FastAPI"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.14"
|
||||||
keywords = ["fastapi", "rate-limiting", "ddos", "security", "api", "420"]
|
keywords = ["fastapi", "rate-limiting", "ddos", "security", "api", "420"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 4 - Beta",
|
"Development Status :: 4 - Beta",
|
||||||
|
|
@ -16,42 +16,43 @@ classifiers = [
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
"Topic :: Internet :: WWW/HTTP",
|
"Topic :: Internet :: WWW/HTTP",
|
||||||
"Topic :: Security",
|
"Topic :: Security",
|
||||||
"Typing :: Typed",
|
"Typing :: Typed",
|
||||||
]
|
]
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.123.0,<1.0.0",
|
"fastapi[standard]>=0.128.7",
|
||||||
"pydantic>=2.12.5,<3.0.0",
|
"pydantic>=2.12.5,<3.0.0",
|
||||||
"pydantic-settings>=2.12.0,<3.0.0",
|
"pydantic-settings>=2.12.0,<3.0.0",
|
||||||
"redis>=7.1.0",
|
"redis>=7.1.1",
|
||||||
"pyjwt>=2.10.0",
|
"pyjwt>=2.11.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.0.2",
|
"pytest>=9.0.2",
|
||||||
"pytest-asyncio>=1.3.0",
|
"pytest-asyncio>=1.3.0",
|
||||||
"pytest-cov>=6.0.0",
|
"pytest-cov>=7.0.0",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"fakeredis>=2.26.0",
|
"fakeredis>=2.33.0",
|
||||||
"time-machine>=2.16.0",
|
"time-machine>=3.2.0",
|
||||||
"asgi-lifespan>=2.1.0",
|
"asgi-lifespan>=2.1.0",
|
||||||
"mypy>=1.19.0",
|
"mypy>=1.19.1",
|
||||||
"types-redis>=4.6.0",
|
"types-redis>=4.6.0.20241004",
|
||||||
"ruff>=0.14.8",
|
"ruff>=0.15.0",
|
||||||
"pylint>=4.0.4",
|
"pylint>=4.0.4",
|
||||||
"pylint-pydantic>=0.4.1",
|
"pylint-pydantic>=0.4.1",
|
||||||
"pylint-per-file-ignores>=3.2.0",
|
"pylint-per-file-ignores>=3.2.0",
|
||||||
"pre-commit>=4.2.0",
|
"pre-commit>=4.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/CarterPerez-dev/fastapi-420"
|
Homepage = "https://github.com/CarterPerez-dev/Cybersecurity-Projects"
|
||||||
Documentation = "https://github.com/CarterPerez-dev/fastapi-420#readme"
|
Documentation = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/blob/main/PROJECTS/advanced/api-rate-limiter/examples/USAGE.md"
|
||||||
Repository = "https://github.com/CarterPerez-dev/fastapi-420"
|
Repository = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/advanced/api-rate-limiter"
|
||||||
Issues = "https://github.com/CarterPerez-dev/fastapi-420/issues"
|
Issues = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/issues"
|
||||||
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|
@ -62,7 +63,7 @@ build-backend = "hatchling.build"
|
||||||
packages = ["src/fastapi_420"]
|
packages = ["src/fastapi_420"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py312"
|
target-version = "py314"
|
||||||
line-length = 88
|
line-length = 88
|
||||||
src = ["src"]
|
src = ["src"]
|
||||||
exclude = ["alembic"]
|
exclude = ["alembic"]
|
||||||
|
|
@ -100,7 +101,7 @@ ignore = [
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.12"
|
python_version = "3.14"
|
||||||
strict = true
|
strict = true
|
||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unused_ignores = true
|
warn_unused_ignores = true
|
||||||
|
|
@ -136,7 +137,7 @@ warn_required_dynamic_aliases = true
|
||||||
|
|
||||||
|
|
||||||
[tool.pylint.main]
|
[tool.pylint.main]
|
||||||
py-version = "3.11"
|
py-version = "3.14"
|
||||||
jobs = 4
|
jobs = 4
|
||||||
load-plugins = [
|
load-plugins = [
|
||||||
"pylint_pydantic",
|
"pylint_pydantic",
|
||||||
|
|
@ -229,7 +230,7 @@ exclude = ["alembic/versions/**", ".venv/**"]
|
||||||
respect-ignore-files = true
|
respect-ignore-files = true
|
||||||
|
|
||||||
[tool.ty.environment]
|
[tool.ty.environment]
|
||||||
python-version = "3.12"
|
python-version = "3.14"
|
||||||
root = ["./src"]
|
root = ["./src"]
|
||||||
python = "./.venv"
|
python = "./.venv"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ from fastapi_420.types import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "1.0.1"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"HTTP_420_ENHANCE_YOUR_CALM",
|
"HTTP_420_ENHANCE_YOUR_CALM",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,135 +0,0 @@
|
||||||
# docksec Makefile
|
|
||||||
# CarterPerez-dev | 2025
|
|
||||||
# MakeFile instead of Justfile so its more compabitle and no need to intsall Just
|
|
||||||
|
|
||||||
BINARY_NAME := docksec
|
|
||||||
MODULE := github.com/CarterPerez-dev/docksec
|
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
|
||||||
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "none")
|
|
||||||
BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
|
|
||||||
LDFLAGS := -ldflags "-s -w \
|
|
||||||
-X main.version=$(VERSION) \
|
|
||||||
-X main.commit=$(COMMIT) \
|
|
||||||
-X main.buildDate=$(BUILD_DATE)"
|
|
||||||
|
|
||||||
GO := go
|
|
||||||
GOFLAGS := -trimpath
|
|
||||||
|
|
||||||
.PHONY: all build clean test lint fmt vet install run help \
|
|
||||||
tools format imports check
|
|
||||||
|
|
||||||
all: build
|
|
||||||
|
|
||||||
build:
|
|
||||||
$(GO) build $(GOFLAGS) $(LDFLAGS) -o bin/$(BINARY_NAME) ./cmd/docksec
|
|
||||||
|
|
||||||
build-all: build-linux build-darwin build-windows
|
|
||||||
|
|
||||||
build-linux:
|
|
||||||
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-amd64 ./cmd/docksec
|
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-arm64 ./cmd/docksec
|
|
||||||
|
|
||||||
build-darwin:
|
|
||||||
GOOS=darwin GOARCH=amd64 $(GO) build $(GOFLAGS) $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-amd64 ./cmd/docksec
|
|
||||||
GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-arm64 ./cmd/docksec
|
|
||||||
|
|
||||||
build-windows:
|
|
||||||
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) $(LDFLAGS) -o bin/$(BINARY_NAME)-windows-amd64.exe ./cmd/docksec
|
|
||||||
|
|
||||||
install:
|
|
||||||
$(GO) install $(GOFLAGS) $(LDFLAGS) ./cmd/docksec
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm -rf bin/
|
|
||||||
$(GO) clean -cache -testcache
|
|
||||||
|
|
||||||
test:
|
|
||||||
$(GO) test -v -race -cover ./...
|
|
||||||
|
|
||||||
test-short:
|
|
||||||
$(GO) test -v -short ./...
|
|
||||||
|
|
||||||
test-coverage:
|
|
||||||
$(GO) test -v -race -coverprofile=coverage.out ./...
|
|
||||||
$(GO) tool cover -html=coverage.out -o coverage.html
|
|
||||||
|
|
||||||
tools:
|
|
||||||
@echo "Installing formatting and linting tools..."
|
|
||||||
go install github.com/segmentio/golines@latest
|
|
||||||
go install mvdan.cc/gofumpt@latest
|
|
||||||
go install github.com/daixiang0/gci@latest
|
|
||||||
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $$(go env GOPATH)/bin v2.7.2
|
|
||||||
@echo "Tools installed successfully"
|
|
||||||
|
|
||||||
format:
|
|
||||||
@which golines > /dev/null || (echo "Run 'make tools' first" && exit 1)
|
|
||||||
golines . -w --max-len=80 --reformat-tags --shorten-comments --formatter=gofumpt
|
|
||||||
|
|
||||||
imports:
|
|
||||||
@which gci > /dev/null || (echo "Run 'make tools' first" && exit 1)
|
|
||||||
gci write . --skip-generated -s standard -s default -s "prefix(github.com/CarterPerez-dev/docksec)"
|
|
||||||
|
|
||||||
lint:
|
|
||||||
@which golangci-lint > /dev/null || (echo "Run 'make tools' first" && exit 1)
|
|
||||||
golangci-lint run ./...
|
|
||||||
|
|
||||||
check: format imports lint
|
|
||||||
@echo "All checks passed"
|
|
||||||
|
|
||||||
fmt:
|
|
||||||
$(GO) fmt ./...
|
|
||||||
|
|
||||||
vet:
|
|
||||||
$(GO) vet ./...
|
|
||||||
|
|
||||||
tidy:
|
|
||||||
$(GO) mod tidy
|
|
||||||
|
|
||||||
verify: fmt vet lint test
|
|
||||||
|
|
||||||
run:
|
|
||||||
$(GO) run ./cmd/docksec $(ARGS)
|
|
||||||
|
|
||||||
run-scan:
|
|
||||||
$(GO) run ./cmd/docksec scan
|
|
||||||
|
|
||||||
docker-build:
|
|
||||||
docker build -t $(BINARY_NAME):$(VERSION) -t $(BINARY_NAME):latest .
|
|
||||||
|
|
||||||
docker-run:
|
|
||||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock $(BINARY_NAME):latest scan
|
|
||||||
|
|
||||||
help:
|
|
||||||
@echo "docksec - Docker Security Audit Tool"
|
|
||||||
@echo ""
|
|
||||||
@echo "Usage:"
|
|
||||||
@echo " make build Build binary for current platform"
|
|
||||||
@echo " make build-all Build binaries for all platforms"
|
|
||||||
@echo " make install Install to GOPATH/bin"
|
|
||||||
@echo " make clean Remove build artifacts"
|
|
||||||
@echo ""
|
|
||||||
@echo "Testing:"
|
|
||||||
@echo " make test Run tests with race detection"
|
|
||||||
@echo " make test-coverage Generate coverage report"
|
|
||||||
@echo ""
|
|
||||||
@echo "Code Quality (run 'make tools' first):"
|
|
||||||
@echo " make tools Install golines, gofumpt, gci, golangci-lint"
|
|
||||||
@echo " make format Format code (golines + gofumpt, max-len=80)"
|
|
||||||
@echo " make imports Organize imports (gci)"
|
|
||||||
@echo " make lint Run golangci-lint"
|
|
||||||
@echo " make check Run format + imports + lint"
|
|
||||||
@echo ""
|
|
||||||
@echo "Legacy/Quick:"
|
|
||||||
@echo " make fmt Run go fmt"
|
|
||||||
@echo " make vet Run go vet"
|
|
||||||
@echo " make tidy Run go mod tidy"
|
|
||||||
@echo " make verify Run fmt, vet, lint, and test"
|
|
||||||
@echo ""
|
|
||||||
@echo "Run:"
|
|
||||||
@echo " make run Run with ARGS='...'"
|
|
||||||
@echo " make run-scan Run scan command"
|
|
||||||
@echo " make docker-build Build Docker image"
|
|
||||||
@echo " make docker-run Run scan in Docker"
|
|
||||||
@echo ""
|
|
||||||
@echo " make help Show this help"
|
|
||||||
Loading…
Reference in New Issue