Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Idiomatic Redis integration for FastAPI - connection management and DI-based cac

- **Fluent setup** — `FastAPIRedis(app).lifespan().caching()` configures pools and caching in one chain, attaching to the [FastAPI lifespan events](https://fastapi.tiangolo.com/advanced/events/)
- **Dependency injection** — `cache()`, `cache_evict()`, `cache_put()` as `Depends()` factories, plus `CacheBackend` for complex invalidation and conditional logic
- **Rate limiting** — `rate_limit()` as `Depends()` factory with INCREX and atomic Lua fallback
- **HTTP-native caching** — [`ETag`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag), [`304 Not Modified`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/304), [`Cache-Control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control) directives out of the box
- **Testable** — full `dependency_overrides` support; no need for monkey-patching
- **Pydantic-validated configuration** — fully configurable via environment variables or via an `.env` file
Expand Down Expand Up @@ -122,6 +123,29 @@ Provides `get`/`set`/`delete`/`has`/`delete_group` with automatic JSON serializa

See the [Caching Guide](docs/guide/caching.md) for detailed examples, feature comparison, and best practices.

## Rate Limiting

Redis-backed fixed-window rate limiting as a FastAPI dependency:

```python
from fastapi import Depends
from redis_fastapi import FastAPIRedis, rate_limit

app = FastAPI()
FastAPIRedis(app).lifespan()

@app.get("/items", dependencies=[Depends(rate_limit(limit=100, window=60))])
async def get_items():
return {"ok": True}
```

Uses [`INCREX`](https://redis.io/docs/latest/commands/increx/) when available (Redis 8.8+)
and falls back to an atomic Lua script for older versions. Supports custom key builders,
sync endpoints, and OpenTelemetry instrumentation.

See the [Rate Limiting Guide](docs/guide/rate-limiting.md) for detailed configuration,
OTel setup, and testing patterns.

## Configuration

All settings are read from environment variables (prefixed `REDIS_`) or a `.env` file. Set `REDIS_URL` for the simplest setup:
Expand Down
246 changes: 246 additions & 0 deletions docs/guide/rate-limiting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
# Rate Limiting

fastapi-redis-sdk provides Redis-backed **fixed-window rate limiting** as a
FastAPI dependency. It uses [`INCREX`](https://redis.io/docs/latest/commands/increx/)
when the Redis server supports it (Redis OSS 8.8+) and falls back to an
atomic Lua script otherwise — all transparently.

| Feature | Support |
|---------|---------|
| Fixed-window counter | ✅ |
| `INCREX` (Redis 8.8+) | ✅ |
| Lua fallback (Redis 7.4+) | ✅ |
| One-time capability detection | ✅ |
| Custom key builder (sync/async) | ✅ |
| Custom key prefix | ✅ |
| Async and sync endpoints | ✅ |
| OpenTelemetry spans & metrics | ✅ |
| Rate-limit response headers (`Retry-After`) | ✅ |

---

## 1. Basic usage

```python
from fastapi import Depends, FastAPI
from redis_fastapi import FastAPIRedis, rate_limit

app = FastAPI()
FastAPIRedis(app).lifespan()

@app.get("/items", dependencies=[Depends(rate_limit(limit=100, window=60))])
async def get_items():
return {"ok": True}
```

This allows **up to 100 requests per 60-second window** per client IP + method + path.

### Parameters

| Argument | Type | Default | Description |
|---------------|----------|---------|-------------|
| `limit` | `int` | (required) | Maximum requests in the window. Must be ≥ 1. |
| `window` | `int` | (required) | Window length in seconds. Must be ≥ 1. |
| `key_builder` | `Callable` | `default_rate_limit_key_builder` | Sync or async function that returns a rate-limit key string. Receives the `Request` object. |
| `prefix` | `str` | `"redis:fastapi:ratelimit"` | Redis key prefix. |

---

## 2. How it works

### Fixed-window counter

- The first request creates a Redis counter with a TTL.
- Each request increments the counter.
- When the counter reaches `limit`, further requests are rejected with
`429 Too Many Requests`.
- After the TTL expires, the counter resets and requests are allowed again.

### Redis command path

When the connected server supports INCREX (Redis OSS 8.8+), the dependency
executes a single atomic command per request:

```redis
INCREX redis:fastapi:ratelimit:<key> BYINT 1 UBOUND <limit> EX <window> ENX
```

The `ENX` flag ensures the TTL is set **only when the key is created**,
preserving fixed-window semantics.

### Lua fallback

When INCREX is unavailable, the library falls back to an equivalent Lua script
that performs the same atomic increment, bound check, and TTL management.

### One-time detection

INCREX capability is detected on the first rate-limit request and cached in the
application state. Subsequent requests use the known path directly — no
per-request fallback overhead.

---

## 3. Rate-limit key

### Default key

The default key uses the client IP, HTTP method, and URL path:

```
redis:fastapi:ratelimit:127.0.0.1:GET:api:v1:items
```

The default key builder is exported as
`redis_fastapi.rate_limit.default_rate_limit_key_builder`.

### Custom key builder

Pass a `key_builder` function to scope limits differently — for example, by
authenticated user ID:

```python
from fastapi import Request

def user_key_builder(request: Request) -> str:
return request.headers.get("X-User-Id", "anonymous")

@app.get("/profile", dependencies=[Depends(rate_limit(limit=30, window=60, key_builder=user_key_builder))])
async def get_profile():
return {"user": "data"}
```

Async key builders are supported — if the function is a coroutine, it is
awaited automatically.

### Custom prefix

Override the Redis key prefix if the default conflicts with other keys:

```python
Depends(rate_limit(limit=100, window=60, prefix="myapp:ratelimit"))
```

---

## 4. Behaviour on block

When a request exceeds the limit, the dependency raises `HTTPException` with:

- **Status code:** `429 Too Many Requests`
- **Detail:** `"Too Many Requests"`
- **Header:** `Retry-After: <seconds>`

The endpoint body is never executed for blocked requests.

---

## 5. Error handling

If Redis is unreachable or returns an error, the rate limiter **fails open**:
the request is allowed, the error is logged, and telemetry records
`result="error"`.

This matches the project's caching error policy — a Redis outage should not
take down the application.

---

## 6. OpenTelemetry

When OTel is enabled (via `FastAPIRedis(app).otel()` or
`redis_fastapi.enable_telemetry()`), rate-limit operations emit:

### Span

| Name | Attributes |
|------|------------|
| `rate_limit.check` | `rate_limit.key`, `rate_limit.limit`, `rate_limit.window`, `rate_limit.allowed`, `rate_limit.remaining`, `rate_limit.backend` (increx or lua) |

### Metric

| Name | Type | Labels |
|------|------|--------|
| `redis_fastapi.rate_limit.requests` | Counter | `result` (allowed, blocked, error) |

---

## 7. Sync endpoints

The rate-limit dependency is an `async def` dependency, so FastAPI handles it
correctly before both async and sync endpoints:

```python
@app.get("/sync", dependencies=[Depends(rate_limit(limit=5, window=60))])
def sync_endpoint():
return {"ok": True}
```

---

## 8. Testing

Use `dependency_overrides` to swap the real Redis client for a fake:

```python
import fakeredis.aioredis
from fastapi.testclient import TestClient
from redis_fastapi import FastAPIRedis, rate_limit, get_async_redis

app = FastAPI()
FastAPIRedis(app).lifespan()

@app.get("/limited", dependencies=[Depends(rate_limit(limit=3, window=60))])
async def limited():
return {"ok": True}

fake = fakeredis.aioredis.FakeRedis()
app.dependency_overrides[get_async_redis] = lambda: fake

with TestClient(app) as client:
r1 = client.get("/limited")
assert r1.status_code == 200
r2 = client.get("/limited")
assert r2.status_code == 200
r3 = client.get("/limited")
assert r3.status_code == 200
r4 = client.get("/limited")
assert r4.status_code == 429
```

---

## 9. Reference

```python
redis_fastapi.rate_limit(
limit: int,
window: int,
*,
key_builder: Callable[[Request], str | Awaitable[str]] | None = None,
prefix: str | None = None,
) -> Depends
```

### Result object

`RateLimitResult` (dataclass) is the internal result type, also exported from
`redis_fastapi`:

| Field | Type | Description |
|--------------|-----------|-------------|
| `allowed` | `bool` | Whether the request is allowed |
| `current` | `int` | Current counter value after the request |
| `remaining` | `int` | Requests remaining in the window |
| `retry_after` | `int` | Seconds until the window resets |
| `backend` | `str` | `"increx"` or `"lua"` |

### Default key builder

```python
redis_fastapi.rate_limit.default_rate_limit_key_builder(
request: Request,
) -> str
```

Returns `"{client_ip}:{method}:{path}"`.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ caching with automatic key consistency.
- **Fluent setup** — `FastAPIRedis(app).lifespan().caching()` configures pools and caching in one chain, attaching to the [FastAPI lifespan events](https://fastapi.tiangolo.com/advanced/events/)
- **Dependency injection** — `cache()`, `cache_evict()`, `cache_put()` as `Depends()` factories, plus `CacheBackend` for complex invalidation and conditional logic
- **HTTP-native caching** — [`ETag`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag), [`304 Not Modified`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/304), [`Cache-Control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control) directives out of the box
- **Rate limiting** — `rate_limit()` as `Depends()` factory with INCREX and atomic Lua fallback
- **Testable** — full `dependency_overrides` support; no need for monkey-patching
- **Pydantic-validated configuration** — fully configurable via environment variables or via an `.env` file

Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ nav:
- User Guide:
- Architecture: guide/architecture.md
- Caching: guide/caching.md
- Rate Limiting: guide/rate-limiting.md
- Configuration: guide/configuration.md
- Benchmarks: guide/benchmarks.md
- API Reference:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ test = [
"coverage[toml]>=7.10.1",
"fakeredis>=2.36.2",
"httpx>=0.23.0,<1.0.0",
"lupa>=2.8",
"pytest>=9.0.0",
"pytest-asyncio>=1.1.0",
"pytest-cov>=6.2.1",
Expand Down
8 changes: 8 additions & 0 deletions src/redis_fastapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
get_sync_cache_backend,
)
from redis_fastapi.lifespan import redis_lifespan
from redis_fastapi.rate_limit import (
RateLimitResult,
default_rate_limit_key_builder,
rate_limit,
)
from redis_fastapi.setup import FastAPIRedis
from redis_fastapi.telemetry import disable_telemetry, enable_telemetry
from redis_fastapi.types import Coder, JsonCoder, KeyBuilder
Expand All @@ -42,11 +47,14 @@
"cache_evict",
"cache_put",
"default_key_builder",
"default_rate_limit_key_builder",
"disable_telemetry",
"enable_telemetry",
"get_async_redis",
"get_cache_backend",
"get_settings",
"get_sync_cache_backend",
"RateLimitResult",
"rate_limit",
"redis_lifespan",
]
4 changes: 4 additions & 0 deletions src/redis_fastapi/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ class _PoolState:
async_cluster: AsyncRedisCluster | None = None
_async_client: AsyncRedis | None = None

# Rate-limit INCREX capability state (unknown/supported/unsupported)
increx_supported: str = "unknown"

# -- pool / cluster builders (static) -----------------------------------

@staticmethod
Expand Down Expand Up @@ -92,6 +95,7 @@ def get_async_client(self) -> AsyncClient:
def clear(self) -> None:
"""Reset cached clients (called during lifespan shutdown)."""
self._async_client = None
self.increx_supported = "unknown"


def _get_pool_state(app: FastAPI) -> _PoolState:
Expand Down
Loading