diff --git a/Dockerfile b/Dockerfile index bcae70d..8171bd8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,13 +31,13 @@ FROM base AS development RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-editable -# Create non-root user -RUN groupadd -r rentcache && useradd -r -g rentcache rentcache -RUN chown -R rentcache:rentcache /app -USER rentcache +# Create non-root user with home directory +RUN groupadd -r rentcache && useradd -r -g rentcache -m rentcache -# Create data directories -RUN mkdir -p /app/data /app/logs +# Create data directories and set permissions +RUN mkdir -p /app/data /app/logs /home/rentcache/.cache +RUN chown -R rentcache:rentcache /app /home/rentcache +USER rentcache EXPOSE 8000 @@ -47,13 +47,13 @@ CMD ["uv", "run", "rentcache", "server", "--host", "0.0.0.0", "--port", "8000", # Production stage FROM base AS production -# Create non-root user -RUN groupadd -r rentcache && useradd -r -g rentcache rentcache -RUN chown -R rentcache:rentcache /app -USER rentcache +# Create non-root user with home directory +RUN groupadd -r rentcache && useradd -r -g rentcache -m rentcache -# Create data directories -RUN mkdir -p /app/data /app/logs +# Create data directories and set permissions +RUN mkdir -p /app/data /app/logs /home/rentcache/.cache +RUN chown -R rentcache:rentcache /app /home/rentcache +USER rentcache # Compile bytecode for better performance ENV UV_COMPILE_BYTECODE=1 diff --git a/README.md b/README.md index a817ca9..0e5b8c7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ **RentCache** is a production-ready FastAPI proxy server that sits between your applications and the Rentcast API, providing intelligent caching, cost optimization, and usage analytics. Perfect for real estate applications that need frequent property data access without breaking the budget. -**🌐 Live Demo**: [https://rentcache.l.supported.systems](https://rentcache.l.supported.systems) [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) [![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-00a393.svg)](https://fastapi.tiangolo.com) @@ -74,7 +73,6 @@ docker network create caddy 2>/dev/null || true docker compose up --build -d ``` -**✅ Ready!** RentCache is now available at: **https://rentcache.l.supported.systems** ### Local Development @@ -102,11 +100,7 @@ uv run rentcache server --reload 2. **Make API calls**: ```bash - # Production endpoint - curl -H "Authorization: Bearer your_rentcast_api_key" \ - "https://rentcache.l.supported.systems/api/v1/properties?city=Austin&state=TX" - - # Local development + # Make API call curl -H "Authorization: Bearer your_rentcast_api_key" \ "http://localhost:8000/api/v1/properties?city=Austin&state=TX" ``` @@ -114,10 +108,10 @@ uv run rentcache server --reload 3. **Monitor performance**: ```bash # View metrics - curl "https://rentcache.l.supported.systems/metrics" + curl "http://localhost:8000/metrics" # Health check - curl "https://rentcache.l.supported.systems/health" + curl "http://localhost:8000/health" ``` ## 📚 Documentation diff --git a/src/rentcache/cache.py b/src/rentcache/cache.py index d54aaca..667496a 100644 --- a/src/rentcache/cache.py +++ b/src/rentcache/cache.py @@ -51,48 +51,49 @@ class CacheBackend: class SQLiteCacheBackend(CacheBackend): """SQLite-based cache backend for persistent storage.""" - def __init__(self, db_session: AsyncSession): - self.db = db_session + def __init__(self, session_factory): + self.SessionLocal = session_factory async def get(self, key: str) -> Optional[Dict[str, Any]]: """Get cached data from SQLite.""" - try: - stmt = select(CacheEntry).where( - and_( - CacheEntry.cache_key == key, - CacheEntry.is_valid == True + async with self.SessionLocal() as db: + try: + stmt = select(CacheEntry).where( + and_( + CacheEntry.cache_key == key, + CacheEntry.is_valid == True + ) ) - ) - result = await self.db.execute(stmt) - entry = result.scalar_one_or_none() - - if not entry: + result = await db.execute(stmt) + entry = result.scalar_one_or_none() + + if not entry: + return None + + # Check expiration + if entry.is_expired(): + logger.debug(f"Cache entry expired: {key}") + # Mark as invalid but don't delete (soft delete) + await self._mark_invalid(entry.id, db) + return None + + # Update access statistics + entry.increment_hit() + await db.commit() + + logger.debug(f"Cache hit: {key}") + return { + 'data': entry.get_response_data(), + 'status_code': entry.status_code, + 'headers': json.loads(entry.headers_json) if entry.headers_json else {}, + 'cached_at': entry.created_at, + 'expires_at': entry.expires_at, + 'hit_count': entry.hit_count + } + + except Exception as e: + logger.error(f"Error getting cache entry {key}: {e}") return None - - # Check expiration - if entry.is_expired(): - logger.debug(f"Cache entry expired: {key}") - # Mark as invalid but don't delete (soft delete) - await self._mark_invalid(entry.id) - return None - - # Update access statistics - entry.increment_hit() - await self.db.commit() - - logger.debug(f"Cache hit: {key}") - return { - 'data': entry.get_response_data(), - 'status_code': entry.status_code, - 'headers': json.loads(entry.headers_json) if entry.headers_json else {}, - 'cached_at': entry.created_at, - 'expires_at': entry.expires_at, - 'hit_count': entry.hit_count - } - - except Exception as e: - logger.error(f"Error getting cache entry {key}: {e}") - return None async def set( self, @@ -101,50 +102,51 @@ class SQLiteCacheBackend(CacheBackend): ttl: Optional[int] = None ) -> bool: """Store data in SQLite cache.""" - try: - ttl = ttl or 3600 # Default 1 hour - expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl) - - # Check if entry exists - stmt = select(CacheEntry).where(CacheEntry.cache_key == key) - result = await self.db.execute(stmt) - existing_entry = result.scalar_one_or_none() - - if existing_entry: - # Update existing entry - existing_entry.response_data = json.dumps(data.get('data', {})) - existing_entry.status_code = data.get('status_code', 200) - existing_entry.headers_json = json.dumps(data.get('headers', {})) - existing_entry.is_valid = True - existing_entry.expires_at = expires_at - existing_entry.ttl_seconds = ttl - existing_entry.updated_at = datetime.now(timezone.utc) - existing_entry.estimated_cost = data.get('estimated_cost', 0.0) - else: - # Create new entry - new_entry = CacheEntry( - cache_key=key, - endpoint=data.get('endpoint', ''), - method=data.get('method', 'GET'), - params_hash=data.get('params_hash', ''), - params_json=json.dumps(data.get('params', {})), - response_data=json.dumps(data.get('data', {})), - status_code=data.get('status_code', 200), - headers_json=json.dumps(data.get('headers', {})), - expires_at=expires_at, - ttl_seconds=ttl, - estimated_cost=data.get('estimated_cost', 0.0) - ) - self.db.add(new_entry) - - await self.db.commit() - logger.debug(f"Cache set: {key} (TTL: {ttl}s)") - return True - - except Exception as e: - logger.error(f"Error setting cache entry {key}: {e}") - await self.db.rollback() - return False + async with self.SessionLocal() as db: + try: + ttl = ttl or 3600 # Default 1 hour + expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl) + + # Check if entry exists + stmt = select(CacheEntry).where(CacheEntry.cache_key == key) + result = await db.execute(stmt) + existing_entry = result.scalar_one_or_none() + + if existing_entry: + # Update existing entry + existing_entry.response_data = json.dumps(data.get('data', {})) + existing_entry.status_code = data.get('status_code', 200) + existing_entry.headers_json = json.dumps(data.get('headers', {})) + existing_entry.is_valid = True + existing_entry.expires_at = expires_at + existing_entry.ttl_seconds = ttl + existing_entry.updated_at = datetime.now(timezone.utc) + existing_entry.estimated_cost = data.get('estimated_cost', 0.0) + else: + # Create new entry + new_entry = CacheEntry( + cache_key=key, + endpoint=data.get('endpoint', ''), + method=data.get('method', 'GET'), + params_hash=data.get('params_hash', ''), + params_json=json.dumps(data.get('params', {})), + response_data=json.dumps(data.get('data', {})), + status_code=data.get('status_code', 200), + headers_json=json.dumps(data.get('headers', {})), + expires_at=expires_at, + ttl_seconds=ttl, + estimated_cost=data.get('estimated_cost', 0.0) + ) + db.add(new_entry) + + await db.commit() + logger.debug(f"Cache set: {key} (TTL: {ttl}s)") + return True + + except Exception as e: + logger.error(f"Error setting cache entry {key}: {e}") + await db.rollback() + return False async def delete(self, key: str) -> bool: """Soft delete cache entry.""" @@ -189,12 +191,12 @@ class SQLiteCacheBackend(CacheBackend): logger.error(f"SQLite health check failed: {e}") return False - async def _mark_invalid(self, entry_id: int): + async def _mark_invalid(self, entry_id: int, db: AsyncSession): """Mark specific entry as invalid.""" stmt = update(CacheEntry).where( CacheEntry.id == entry_id ).values(is_valid=False) - await self.db.execute(stmt) + await db.execute(stmt) async def _mark_invalid_by_key(self, key: str): """Mark entry invalid by cache key.""" diff --git a/src/rentcache/models.py b/src/rentcache/models.py index 613e4b5..d50c768 100644 --- a/src/rentcache/models.py +++ b/src/rentcache/models.py @@ -67,7 +67,16 @@ class CacheEntry(Base, TimestampMixin): """Check if cache entry is expired.""" if not self.expires_at: return False - return datetime.now(timezone.utc) > self.expires_at + + # Ensure both datetimes are timezone-aware for comparison + now = datetime.now(timezone.utc) + expires_at = self.expires_at + + # If expires_at is naive, assume it's in UTC + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + + return now > expires_at def increment_hit(self): """Increment hit counter and update last accessed.""" diff --git a/src/rentcache/server.py b/src/rentcache/server.py index fbe63c4..cd59f4f 100644 --- a/src/rentcache/server.py +++ b/src/rentcache/server.py @@ -22,7 +22,7 @@ from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded from slowapi.middleware import SlowAPIMiddleware from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy import select, func, and_ +from sqlalchemy import select, func, and_, case from pydantic import BaseModel, Field from .models import ( @@ -94,27 +94,25 @@ async def lifespan(app: FastAPI): ) # Initialize cache manager - session = SessionLocal() + cache_backend = SQLiteCacheBackend(SessionLocal) + cache_manager = CacheManager( + backend=cache_backend, + default_ttl=DEFAULT_TTL_SECONDS, + stale_while_revalidate=True + ) + + # HTTP client for upstream requests + http_client = httpx.AsyncClient( + timeout=30.0, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20) + ) + + logger.info("RentCache server started successfully") + try: - cache_backend = SQLiteCacheBackend(session) - cache_manager = CacheManager( - backend=cache_backend, - default_ttl=DEFAULT_TTL_SECONDS, - stale_while_revalidate=True - ) - - # HTTP client for upstream requests - http_client = httpx.AsyncClient( - timeout=30.0, - limits=httpx.Limits(max_connections=100, max_keepalive_connections=20) - ) - - logger.info("RentCache server started successfully") yield - finally: # Cleanup - await session.close() await http_client.aclose() await engine.dispose() logger.info("RentCache server shut down") @@ -418,10 +416,13 @@ async def proxy_to_rentcast( ) # Make request to Rentcast API - # Note: We need to store the original Rentcast API key separately from our hash - # For now, this is a placeholder - in production, you'd need to decrypt/retrieve the original key + # Extract the original Rentcast API key from the request headers + # The bearer token is the actual Rentcast API key + auth_header = request.headers.get("authorization", "") + rentcast_key = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else "" + headers = { - "X-Api-Key": "RENTCAST_API_KEY_PLACEHOLDER", # Replace with actual key retrieval + "X-Api-Key": rentcast_key, "Content-Type": "application/json" } @@ -533,7 +534,7 @@ async def health_check(db: AsyncSession = Depends(get_db)): twenty_four_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24) hit_ratio_stmt = select( func.count(UsageStats.id).label('total'), - func.sum(func.case((UsageStats.cache_hit == True, 1), else_=0)).label('hits') + func.sum(case((UsageStats.cache_hit == True, 1), else_=0)).label('hits') ).where(UsageStats.created_at >= twenty_four_hours_ago) hit_ratio_result = await db.execute(hit_ratio_stmt)