Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ffe6d98
feat: add Redis cache configuration and dependencies
seapagan Jan 1, 2026
7b3ec1a
feat: initialize Redis cache backend with graceful fallback
seapagan Jan 1, 2026
3f61c62
feat: add cache decorator utilities and key builders
seapagan Jan 1, 2026
0f2844c
feat: add caching to GET /users/me endpoint
seapagan Jan 1, 2026
8f8fede
refactor: make PickleCoder the default coder for cache decorator
seapagan Jan 1, 2026
f704470
fix: correct cache key structure for proper invalidation
seapagan Jan 1, 2026
49f07fd
update .gitignore
seapagan Jan 1, 2026
4a0ef26
feat: add caching to GET /users/ endpoint
seapagan Jan 1, 2026
6ea26be
feat: add cache activity logging middleware
seapagan Jan 1, 2026
b1000eb
update .gitignore
seapagan Jan 1, 2026
dcd651d
fix: URL-encode Redis password to handle special characters
seapagan Jan 1, 2026
5ccab63
fix: prevent cache fixture race conditions in tests
seapagan Jan 1, 2026
8a7c769
feat: add security warning for Redis without authentication
seapagan Jan 1, 2026
4e380c0
fix: address PR review feedback for caching implementation
seapagan Jan 1, 2026
3a9d6a3
add types-redis package and remove unneeded type: ignore comments
seapagan Jan 2, 2026
c133d5f
feat: add caching to API keys endpoints
seapagan Jan 2, 2026
73a48e8
refactor: remove debug-oriented NO CACHE HEADER logging
seapagan Jan 2, 2026
7e89d9e
refactor: rename invalidate_pattern to invalidate_namespace
seapagan Jan 2, 2026
235646a
test: add cache and redis url coverage
seapagan Jan 2, 2026
33780a1
test: cover the redis fallback in lifespan()
seapagan Jan 2, 2026
a0970e2
test: stabilize admin self-ban coverage
seapagan Jan 2, 2026
656af88
test: cover cache hit logging
seapagan Jan 2, 2026
45bc9ee
test: coverage in CI was down d/t no redis - add extra tests to compe…
seapagan Jan 2, 2026
7494bee
refactor: make caching opt-in (disabled by default)
seapagan Jan 2, 2026
2e86b1f
docs: add comprehensive caching documentation
seapagan Jan 2, 2026
d1a9d48
docs: update caching performance numbers with realistic benchmarks
seapagan Jan 2, 2026
ca268f9
fix: add async-timeout dependency for Python versions < 3.12
seapagan Jan 2, 2026
f2ba9eb
refactor: change a couple areas from PR review
seapagan Jan 2, 2026
a9f74b8
minor gramatical fixes
seapagan Jan 2, 2026
43087b8
docs: add caching feature to README and project organization
seapagan Jan 2, 2026
147b867
fix: add Redis client cleanup on connection failure and improve docs
seapagan Jan 2, 2026
29769fe
docs: fix broken anchor link in caching documentation
seapagan Jan 2, 2026
2f8dd06
docs: add redis-py 4.6.0 compatibility note and update TODO
seapagan Jan 2, 2026
08dad6d
fix: avoid loguru enqueue with uvicorn reload
seapagan Jan 2, 2026
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
32 changes: 29 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ LOG_RETENTION=30 days
LOG_COMPRESSION=zip

# What to log - choose one or combine with commas:
# ALL - Log everything (recommended for development/debugging)
# ALL - Log everything (recommended for
# development/debugging)
# NONE - Disable logging
# REQUESTS - HTTP request/response logging
# AUTH - Authentication, login, token operations
Expand All @@ -100,11 +101,14 @@ LOG_COMPRESSION=zip
# ERRORS - Error conditions (always recommended)
# ADMIN - Admin panel operations
# API_KEYS - API key operations
# CACHE - Cache operations (Redis, invalidation)
#
# Examples:
# LOG_CATEGORIES=ALL # Log everything
# LOG_CATEGORIES=ERRORS,AUTH,DATABASE # Only log errors, auth, and database operations
# LOG_CATEGORIES=REQUESTS,ERRORS # Only log requests and errors
# LOG_CATEGORIES=ERRORS,AUTH,DATABASE # Only log errors,
# auth, and database operations
# LOG_CATEGORIES=REQUESTS,ERRORS # Only log requests and
# errors
LOG_CATEGORIES=ALL

# Set the log filename (default: api.log)
Expand All @@ -123,3 +127,25 @@ LOG_CONSOLE_ENABLED=false
# Includes HTTP performance metrics and custom business metrics
# See: docs/usage/metrics.md for details
METRICS_ENABLED=false

# Cache Configuration (opt-in, disabled by default)
# Enable response caching for improved API performance on read-heavy
# endpoints
# When CACHE_ENABLED=true and REDIS_ENABLED=false, uses in-memory
# cache (suitable for single-instance deployments)
# When CACHE_ENABLED=true and REDIS_ENABLED=true, uses Redis for
# distributed caching
# See: docs/usage/caching.md for details
CACHE_ENABLED=false

# Redis backend settings (when CACHE_ENABLED=true)
REDIS_ENABLED=false
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0

# Default cache TTL (Time To Live) in seconds
# How long cached responses are stored before expiring
# Individual endpoints may override this value
CACHE_DEFAULT_TTL=300
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,5 @@ CLAUDE.md
.clinerules
.cursorrules
.windsurfrules
NOTES.md
AGENTS.md
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ repos:
additional_dependencies:
- pydantic
- sqladmin
- types-redis

- repo: https://github.com/astral-sh/uv-pre-commit
# uv version.
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ following advantages to starting your own from scratch :
with configurable log levels, rotation, retention, and compression. Control
what gets logged via `LOG_CATEGORIES` (requests, auth, database, errors,
etc.).
- **Optional response caching** for improved performance and reduced database
load. Disabled by default, can be enabled with in-memory or Redis backend.
Includes cache invalidation utilities, monitoring, and configurable TTL.
Cache hits typically 5-30x faster than database queries (depending on query
complexity). Tests run with in-memory caching enabled.
- **Prometheus metrics** for production observability. Optional metrics
collection tracks HTTP performance (requests, latency, in-flight), business
metrics (auth failures, API key usage, login attempts), and custom
Expand Down
21 changes: 15 additions & 6 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
- Update return status codes and examples in the API documentation to be more
accurate and useful, some of them have wrong status codes and/or examples. For
example, the `GET /verify/` endpoint should return a 204 status code, not 200.
- Consider evaluating `structlog` as an alternative to `loguru` for logging.

## Quotas

Expand All @@ -86,12 +87,20 @@ Add Quota functionality.

## Caching

- Add a Redis cache to the endpoints.
[fastapi-redis-cache](https://pypi.org/project/fastapi-redis-cache/) should
make this reasonably painless. Note that project seems to be abandoned with a
lot of un-merged PRs so I have forked and updated the project to fix a few
existing bugs, merge some PRs and add some new features. I'm still putting the
finishing touches on it but it should be ready soon.
- ✅ **DONE**: Basic Redis caching implemented using `fastapi-cache2==0.2.2`
- **Upgrade redis-py client**: Current `fastapi-cache2==0.2.2` (PyPI) requires
redis-py 4.6.0. The GitHub repo has been updated to support redis-py 5.x+,
but no new release published. Options to investigate:
- Wait for new PyPI release of fastapi-cache2
- Fork fastapi-cache2 and publish our own updated version
- Use git HEAD version instead of PyPI package
- Switch to alternative caching library (fastapi-cache, fastapi-redis-cache-reborn)
- **Cache TTL per-endpoint**: Allow configuring different TTL values per
endpoint instead of global default
- **Cache warming strategies**: Preload frequently accessed data on startup
- **Cache statistics/metrics**: Integration with Prometheus metrics for cache
hit/miss rates, memory usage, etc.
- **Cache size limits**: Implement eviction policies and memory limits

## Frontend

Expand Down
32 changes: 32 additions & 0 deletions app/cache/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Cache module for FastAPI application.

Provides cache decorators, key builders, and invalidation utilities.
"""

from app.cache.decorators import cached
from app.cache.invalidation import (
invalidate_api_keys_cache,
invalidate_namespace,
invalidate_user_cache,
invalidate_users_list_cache,
)
from app.cache.key_builders import (
api_key_single_key_builder,
api_keys_list_key_builder,
paginated_key_builder,
user_scoped_key_builder,
users_list_key_builder,
)

__all__ = [
"api_key_single_key_builder",
"api_keys_list_key_builder",
"cached",
"invalidate_api_keys_cache",
"invalidate_namespace",
"invalidate_user_cache",
"invalidate_users_list_cache",
"paginated_key_builder",
"user_scoped_key_builder",
"users_list_key_builder",
]
69 changes: 69 additions & 0 deletions app/cache/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Cache decorator utilities with project-specific defaults."""

from collections.abc import Callable
from typing import Any

from fastapi_cache.coder import Coder, PickleCoder
from fastapi_cache.decorator import cache as _cache

from app.config.settings import get_settings


def cached(
expire: int | None = None,
namespace: str = "",
key_builder: Callable[..., str] | None = None,
coder: type[Coder] | None = None,
) -> Callable[..., Any]:
"""Project-specific cache decorator with defaults.

Wraps fastapi-cache's cache decorator with sensible defaults from
settings. When caching is disabled (CACHE_ENABLED=false), acts as
a no-op decorator that returns the function unchanged.

Args:
expire: Cache TTL in seconds. Uses CACHE_DEFAULT_TTL if None.
namespace: Cache key namespace for organization.
key_builder: Custom function to build cache keys. Uses
default if None.
coder: Custom coder for serialization. Defaults to
PickleCoder for SQLAlchemy ORM models. Use JsonCoder if
caching Pydantic models.

Returns:
Decorated function with caching enabled, or the original
function if caching is disabled.

Example:
```python
from app.cache import cached, user_scoped_key_builder

@router.get("/users/me")
@cached(expire=300, namespace="user",
key_builder=user_scoped_key_builder)
async def get_my_user(
request: Request,
response: Response,
user: User = Depends(AuthManager())
) -> User:
return user
```
"""
# If caching is disabled, return a no-op decorator
if not get_settings().cache_enabled:

def noop_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
return func

return noop_decorator

# Caching is enabled - proceed with normal caching logic
if expire is None:
expire = get_settings().cache_default_ttl

if coder is None:
coder = PickleCoder

return _cache(
expire=expire, namespace=namespace, key_builder=key_builder, coder=coder
)
145 changes: 145 additions & 0 deletions app/cache/invalidation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Cache invalidation utilities.

Provides helper functions to clear cached data when underlying data
changes.

All invalidation functions handle errors gracefully - cache failures
are logged but don't prevent the operation from succeeding. This ensures
the app continues functioning (with stale cache) if the cache backend
fails.
"""

from fastapi_cache import FastAPICache
from redis.exceptions import RedisError

from app.logs import LogCategory, category_logger


async def invalidate_user_cache(user_id: int) -> None:
"""Invalidate all cached data for a specific user.

Clears user-scoped cache entries (e.g., /users/me, /users/keys).
Also clears the single-user lookup in /users/ endpoint.

Args:
user_id: The ID of the user whose cache should be cleared.

Example:
```python
# After user edit
await invalidate_user_cache(user.id)
```

Note:
Cache failures are logged but don't raise exceptions. The app
continues with stale cache until TTL expires.
"""
try:
# Clear /users/me style cache (namespace: "user:{user_id}")
namespace = f"user:{user_id}"
await FastAPICache.clear(namespace=namespace)

# Clear single user lookup from /users/?user_id=X
# (namespace: "users:{user_id}")
users_namespace = f"users:{user_id}"
await FastAPICache.clear(namespace=users_namespace)

category_logger.info(
f"Cleared cache for user {user_id}", LogCategory.CACHE
)
except (RedisError, OSError, RuntimeError) as e:
category_logger.error(
f"Failed to invalidate cache for user {user_id}: {e}",
LogCategory.CACHE,
)


async def invalidate_users_list_cache() -> None:
"""Invalidate the cached users list.

Clears all cached paginated user list entries.
Call this when users are created, deleted, or have role changes.

Example:
```python
# After user creation or deletion
await invalidate_users_list_cache()
```

Note:
Cache failures are logged but don't raise exceptions.
"""
try:
await FastAPICache.clear(namespace="users:list")
category_logger.info("Cleared users list cache", LogCategory.CACHE)
except (RedisError, OSError, RuntimeError) as e:
category_logger.error(
f"Failed to invalidate users list cache: {e}",
LogCategory.CACHE,
)


async def invalidate_api_keys_cache(user_id: int) -> None:
"""Invalidate cached API keys list for a specific user.

Clears API key list cache for the given user.
Call this when API keys are created, updated, or deleted.

Args:
user_id: The ID of the user whose API keys cache should be
cleared.

Example:
```python
# After API key creation/deletion
await invalidate_api_keys_cache(user.id)
```

Note:
Cache failures are logged but don't raise exceptions.
"""
try:
namespace = f"apikeys:{user_id}"
await FastAPICache.clear(namespace=namespace)
category_logger.info(
f"Cleared API keys cache for user {user_id}",
LogCategory.CACHE,
)
except (RedisError, OSError, RuntimeError) as e:
category_logger.error(
f"Failed to invalidate API keys cache for user {user_id}: {e}",
LogCategory.CACHE,
)


async def invalidate_namespace(namespace: str) -> None:
"""Invalidate all cache keys under a namespace.

Clears all cache entries stored under the given namespace prefix.
Useful for custom endpoint groups without dedicated invalidation
helpers.

Args:
namespace: Cache namespace prefix to clear (e.g., "products:123").

Example:
```python
# Clear all caches under "products:123" namespace
await invalidate_namespace("products:123")
```

Note:
Cache failures are logged but don't raise exceptions. The app
continues with stale cache until TTL expires.
"""
try:
await FastAPICache.clear(namespace=namespace)
category_logger.info(
f"Cleared cache namespace: {namespace}",
LogCategory.CACHE,
)
except (RedisError, OSError, RuntimeError) as e:
category_logger.error(
f"Failed to invalidate cache namespace {namespace}: {e}",
LogCategory.CACHE,
)
Loading