feat(loyalty): Google Wallet production readiness — 10 hardening items
Some checks failed
CI / ruff (push) Successful in 12s
CI / validate (push) Successful in 26s
CI / dependency-scanning (push) Successful in 30s
CI / pytest (push) Failing after 3h9m5s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled

- Fix rate limiter to extract real client IP and handle sync/async endpoints
- Rate-limit public enrollment (10/min) and program info (30/min) endpoints
- Add 409 Conflict to non-retryable status codes in retry decorator
- Cache private key in get_save_url() to avoid re-reading JSON per call
- Make update_class() return bool success status with error-level logging
- Move Google Wallet config from core to loyalty module config
- Document time.sleep() safety in retry decorator (threadpool execution)
- Add per-card retry (1 retry, 2s delay) to wallet sync task
- Add logo URL reachability check (HEAD request) to validate_config()
- Add 26 comprehensive unit tests for GoogleWalletService

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 00:18:13 +01:00
parent 366d4b9765
commit b6047f5b7d
8 changed files with 791 additions and 87 deletions

View File

@@ -8,31 +8,78 @@ This module provides classes and functions for:
- Consistent error handling for rate limit violations
"""
import asyncio
from functools import wraps
from app.exceptions.base import RateLimitException # Add this import
from starlette.requests import Request
from app.exceptions.base import RateLimitException
from middleware.cloudflare import get_real_client_ip
from middleware.rate_limiter import RateLimiter
# Initialize rate limiter instance
rate_limiter = RateLimiter()
def _find_request(*args, **kwargs) -> Request | None:
"""Extract a Request object from function args/kwargs."""
# Check kwargs first (FastAPI usually passes request= as keyword)
for val in kwargs.values():
if isinstance(val, Request):
return val
# Check positional args (e.g. self, request, ...)
for val in args:
if isinstance(val, Request):
return val
return None
def rate_limit(max_requests: int = 100, window_seconds: int = 3600):
"""Rate limiting decorator for FastAPI endpoints."""
"""Rate limiting decorator for FastAPI endpoints.
Works with both sync and async endpoint functions.
Extracts the real client IP from the Request object for per-client limiting.
"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
client_id = "anonymous" # In production, extract from request
if asyncio.iscoroutinefunction(func):
if not rate_limiter.allow_request(client_id, max_requests, window_seconds):
# Use custom exception instead of HTTPException
raise RateLimitException(
message="Rate limit exceeded", retry_after=window_seconds
@wraps(func)
async def async_wrapper(*args, **kwargs):
request = _find_request(*args, **kwargs)
client_id = (
get_real_client_ip(request) if request else "anonymous"
)
return await func(*args, **kwargs)
if not rate_limiter.allow_request(
client_id, max_requests, window_seconds
):
raise RateLimitException(
message="Rate limit exceeded",
retry_after=window_seconds,
)
return wrapper
return await func(*args, **kwargs)
return async_wrapper
@wraps(func)
def sync_wrapper(*args, **kwargs):
request = _find_request(*args, **kwargs)
client_id = (
get_real_client_ip(request) if request else "anonymous"
)
if not rate_limiter.allow_request(
client_id, max_requests, window_seconds
):
raise RateLimitException(
message="Rate limit exceeded",
retry_after=window_seconds,
)
return func(*args, **kwargs)
return sync_wrapper
return decorator