release: bump version to 0.3.0
- Refactor Redis backend connection handling and pool management - Update algorithm implementations with improved type annotations - Enhance config loader validation with stricter Pydantic schemas - Improve decorator and middleware error handling - Expand example scripts with better docstrings and usage patterns - Add new 00_basic_usage.py example for quick start - Reorganize examples directory structure - Fix type annotation inconsistencies across core modules - Update dependencies in pyproject.toml
This commit is contained in:
277
docs/user-guide/exception-handling.rst
Normal file
277
docs/user-guide/exception-handling.rst
Normal file
@@ -0,0 +1,277 @@
|
||||
Exception Handling
|
||||
==================
|
||||
|
||||
When a client exceeds their rate limit, FastAPI Traffic raises a ``RateLimitExceeded``
|
||||
exception. This guide covers how to handle it gracefully.
|
||||
|
||||
Default Behavior
|
||||
----------------
|
||||
|
||||
By default, when a rate limit is exceeded, the library raises ``RateLimitExceeded``.
|
||||
FastAPI will convert this to a 500 error unless you handle it.
|
||||
|
||||
The exception contains useful information:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from fastapi_traffic import RateLimitExceeded
|
||||
|
||||
try:
|
||||
# Rate limited operation
|
||||
pass
|
||||
except RateLimitExceeded as exc:
|
||||
print(exc.message) # "Rate limit exceeded"
|
||||
print(exc.retry_after) # Seconds until they can retry (e.g., 45.2)
|
||||
print(exc.limit_info) # RateLimitInfo object with full details
|
||||
|
||||
Custom Exception Handler
|
||||
------------------------
|
||||
|
||||
The most common approach is to register a custom exception handler:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi_traffic import RateLimitExceeded
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.exception_handler(RateLimitExceeded)
|
||||
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
"error": "rate_limit_exceeded",
|
||||
"message": "You're making too many requests. Please slow down.",
|
||||
"retry_after": exc.retry_after,
|
||||
},
|
||||
headers={
|
||||
"Retry-After": str(int(exc.retry_after or 60)),
|
||||
},
|
||||
)
|
||||
|
||||
Now clients get a clean JSON response instead of a generic error.
|
||||
|
||||
Including Rate Limit Headers
|
||||
----------------------------
|
||||
|
||||
The ``limit_info`` object can generate standard rate limit headers:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@app.exception_handler(RateLimitExceeded)
|
||||
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
||||
headers = {}
|
||||
if exc.limit_info:
|
||||
headers = exc.limit_info.to_headers()
|
||||
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
"error": "rate_limit_exceeded",
|
||||
"retry_after": exc.retry_after,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
This adds headers like:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
X-RateLimit-Limit: 100
|
||||
X-RateLimit-Remaining: 0
|
||||
X-RateLimit-Reset: 1709834400
|
||||
Retry-After: 45
|
||||
|
||||
Different Responses for Different Endpoints
|
||||
-------------------------------------------
|
||||
|
||||
You might want different error messages for different parts of your API:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@app.exception_handler(RateLimitExceeded)
|
||||
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
||||
path = request.url.path
|
||||
|
||||
if path.startswith("/api/v1/"):
|
||||
# API clients get JSON
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"error": "rate_limit_exceeded", "retry_after": exc.retry_after},
|
||||
)
|
||||
elif path.startswith("/web/"):
|
||||
# Web users get a friendly HTML page
|
||||
return HTMLResponse(
|
||||
status_code=429,
|
||||
content="<h1>Slow down!</h1><p>Please wait a moment before trying again.</p>",
|
||||
)
|
||||
else:
|
||||
# Default response
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": exc.message},
|
||||
)
|
||||
|
||||
Using the on_blocked Callback
|
||||
-----------------------------
|
||||
|
||||
Instead of (or in addition to) exception handling, you can use the ``on_blocked``
|
||||
callback to run code when a request is blocked:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def log_blocked_request(request: Request, result):
|
||||
"""Log when a request is rate limited."""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
logger.warning(
|
||||
"Rate limit exceeded for %s on %s %s",
|
||||
client_ip,
|
||||
request.method,
|
||||
request.url.path,
|
||||
)
|
||||
|
||||
@app.get("/api/data")
|
||||
@rate_limit(100, 60, on_blocked=log_blocked_request)
|
||||
async def get_data(request: Request):
|
||||
return {"data": "here"}
|
||||
|
||||
The callback receives the request and the rate limit result. It runs before the
|
||||
exception is raised.
|
||||
|
||||
Exempting Certain Requests
|
||||
--------------------------
|
||||
|
||||
Use ``exempt_when`` to skip rate limiting for certain requests:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
def is_admin(request: Request) -> bool:
|
||||
"""Check if request is from an admin."""
|
||||
user = getattr(request.state, "user", None)
|
||||
return user is not None and user.is_admin
|
||||
|
||||
@app.get("/api/data")
|
||||
@rate_limit(100, 60, exempt_when=is_admin)
|
||||
async def get_data(request: Request):
|
||||
return {"data": "here"}
|
||||
|
||||
Admin requests bypass rate limiting entirely.
|
||||
|
||||
Graceful Degradation
|
||||
--------------------
|
||||
|
||||
Sometimes you'd rather serve a degraded response than reject the request entirely:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from fastapi_traffic import RateLimiter, RateLimitConfig
|
||||
from fastapi_traffic.core.limiter import get_limiter
|
||||
|
||||
@app.get("/api/search")
|
||||
async def search(request: Request, q: str):
|
||||
limiter = get_limiter()
|
||||
config = RateLimitConfig(limit=100, window_size=60)
|
||||
|
||||
result = await limiter.check(request, config)
|
||||
|
||||
if not result.allowed:
|
||||
# Return cached/simplified results instead of blocking
|
||||
return {
|
||||
"results": get_cached_results(q),
|
||||
"note": "Results may be stale. Please try again later.",
|
||||
"retry_after": result.info.retry_after,
|
||||
}
|
||||
|
||||
# Full search
|
||||
return {"results": perform_full_search(q)}
|
||||
|
||||
Backend Errors
|
||||
--------------
|
||||
|
||||
If the rate limit backend fails (Redis down, SQLite locked, etc.), you have options:
|
||||
|
||||
**Option 1: Fail closed (default)**
|
||||
|
||||
Requests fail when the backend is unavailable. Safer, but impacts availability.
|
||||
|
||||
**Option 2: Fail open**
|
||||
|
||||
Allow requests through when the backend fails:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@app.get("/api/data")
|
||||
@rate_limit(100, 60, skip_on_error=True)
|
||||
async def get_data(request: Request):
|
||||
return {"data": "here"}
|
||||
|
||||
**Option 3: Handle the error explicitly**
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from fastapi_traffic import BackendError
|
||||
|
||||
@app.exception_handler(BackendError)
|
||||
async def backend_error_handler(request: Request, exc: BackendError):
|
||||
# Log the error
|
||||
logger.error("Rate limit backend error: %s", exc.original_error)
|
||||
|
||||
# Decide what to do
|
||||
# Option A: Allow the request
|
||||
return None # Let the request continue
|
||||
|
||||
# Option B: Return an error
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"error": "service_unavailable"},
|
||||
)
|
||||
|
||||
Other Exceptions
|
||||
----------------
|
||||
|
||||
FastAPI Traffic defines a few exception types:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from fastapi_traffic import (
|
||||
RateLimitExceeded, # Rate limit was exceeded
|
||||
BackendError, # Storage backend failed
|
||||
ConfigurationError, # Invalid configuration
|
||||
)
|
||||
|
||||
All inherit from ``FastAPITrafficError``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from fastapi_traffic.exceptions import FastAPITrafficError
|
||||
|
||||
@app.exception_handler(FastAPITrafficError)
|
||||
async def traffic_error_handler(request: Request, exc: FastAPITrafficError):
|
||||
"""Catch-all for FastAPI Traffic errors."""
|
||||
if isinstance(exc, RateLimitExceeded):
|
||||
return JSONResponse(status_code=429, content={"error": "rate_limited"})
|
||||
elif isinstance(exc, BackendError):
|
||||
return JSONResponse(status_code=503, content={"error": "backend_error"})
|
||||
else:
|
||||
return JSONResponse(status_code=500, content={"error": "internal_error"})
|
||||
|
||||
Helper Function
|
||||
---------------
|
||||
|
||||
FastAPI Traffic provides a helper to create rate limit responses:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from fastapi_traffic.core.decorator import create_rate_limit_response
|
||||
|
||||
@app.exception_handler(RateLimitExceeded)
|
||||
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
||||
return create_rate_limit_response(exc, include_headers=True)
|
||||
|
||||
This creates a standard 429 response with all the appropriate headers.
|
||||
Reference in New Issue
Block a user