Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
35237c5
implement websocket gateway with session management and demultiplexin…
harii55 Dec 12, 2025
93f04ce
Implement Redis client for session management, update configuration f…
harii55 Dec 12, 2025
2a0e228
feat: Optimize SessionManager Redis operations with secondary indexing
harii55 Dec 14, 2025
a5fc2dc
feat: Introduce SessionCleanupService for managing stale sessions
harii55 Dec 15, 2025
4c7e2f1
refactor: updated test files
harii55 Dec 15, 2025
afaefe2
refactor: clean up import statements in main.py
harii55 Dec 15, 2025
6b175c6
refactor: reformat files to fix ruff check failures
harii55 Dec 15, 2025
aea9ce4
Merge branch 'dev' of github.com:nerospatial/nerospatial-backend into…
Jenish-1235 Dec 18, 2025
aa33410
feat: Enhance Redis and session management capabilities
Jenish-1235 Dec 19, 2025
ad6e676
refactor: streamline string formatting and method signatures across m…
Jenish-1235 Dec 19, 2025
36baebb
redis dependency fixed
Jenish-1235 Dec 19, 2025
c806a6f
ci : fix python version
Jenish-1235 Dec 19, 2025
47da45b
docker : update python runtime version
Jenish-1235 Dec 19, 2025
db1d9d0
tests : fix all the integration tests
Jenish-1235 Dec 19, 2025
2b4dcf3
ci : update tests ci to use redis
Jenish-1235 Dec 19, 2025
8c44fc5
refactor(redis): optimize session management with Hash-based mappings…
Jenish-1235 Dec 19, 2025
9e94622
update .gitignore
Jenish-1235 Dec 19, 2025
4246a24
fix server startup issues, env loading and added postgres service to …
Jenish-1235 Dec 19, 2025
4d41a4c
fix : remove redundant health endpoint in gateway router, fix None ch…
Jenish-1235 Dec 19, 2025
9ec7ec2
Merge pull request #2 from nerospatial/upgrade-gateway
Jenish-1235 Dec 19, 2025
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
15 changes: 13 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
enable-cache: true

- name: Set up Python
run: uv python install 3.11
run: uv python install 3.13.7

- name: Install dependencies
run: uv sync --extra dev
Expand All @@ -47,6 +47,17 @@ jobs:
runs-on: ubuntu-latest
needs: lint

services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- name: Checkout code
uses: actions/checkout@v4
Expand All @@ -57,7 +68,7 @@ jobs:
enable-cache: true

- name: Set up Python
run: uv python install 3.11
run: uv python install 3.13.7

- name: Install dependencies
run: uv sync --extra dev
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ ENV/
.env.local
.env.*.local
.env
keys/

# OS
.DS_Store
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# Multi-stage build for optimized production image

# Stage 1: Build stage with uv for fast dependency installation
FROM python:3.11-slim AS builder
FROM python:3.13.7-slim AS builder

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
Expand All @@ -28,7 +28,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \


# Stage 2: Production runtime
FROM python:3.11-slim AS runtime
FROM python:3.13.7-slim AS runtime

# Create non-root user for security
RUN groupadd --gid 1000 appgroup && \
Expand Down
10 changes: 4 additions & 6 deletions api/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@
from fastapi.responses import JSONResponse

from core.app_state import AppState
from core.database import verify_database_connection
from core.redis import verify_redis_connection

router = APIRouter(tags=["Health"])

Expand All @@ -29,8 +27,8 @@ async def health_check(request: Request) -> JSONResponse:
state: AppState = request.app.state.app_state

checks = {
"database": await verify_database_connection(state.db_pool),
"redis": await verify_redis_connection(state.redis_client),
"database": state.db_pool is not None and await state.db_pool.ping() if hasattr(state.db_pool, "ping") else state.db_pool is not None,
"redis": await state.redis_client.ping() if state.redis_client else False,
"key_vault": state.key_vault.is_available() if state.key_vault else False,
}

Expand Down Expand Up @@ -69,8 +67,8 @@ async def readiness_check(request: Request) -> JSONResponse:
)

# Verify critical dependencies
db_ok = await verify_database_connection(state.db_pool)
redis_ok = await verify_redis_connection(state.redis_client)
db_ok = state.db_pool is not None and (await state.db_pool.ping() if hasattr(state.db_pool, "ping") else True)
redis_ok = state.redis_client is not None and await state.redis_client.ping()

if not (db_ok and redis_ok):
return JSONResponse(
Expand Down
28 changes: 13 additions & 15 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""

model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
)
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore")

# =========================================================================
# Bootstrap Settings (from .env only)
Expand Down Expand Up @@ -56,6 +54,7 @@ class Settings(BaseSettings):
redis_port: int = 6379
redis_db: int = 0
redis_password: str | None = None
redis_max_connections: int = 50

# =========================================================================
# JWT Authentication
Expand Down Expand Up @@ -106,23 +105,22 @@ def is_development(self) -> bool:
def postgres_url(self) -> str:
"""Build PostgreSQL connection URL."""
if not self.postgres_password:
return (
f"postgresql://{self.postgres_user}@{self.postgres_host}:"
f"{self.postgres_port}/{self.postgres_db}"
)
return (
f"postgresql://{self.postgres_user}:{self.postgres_password}"
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
)
return f"postgresql://{self.postgres_user}@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
return f"postgresql://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"

@property
def redis_url(self) -> str:
"""Build Redis connection URL."""
# Check for explicit REDIS_URL environment variable first (useful for Docker Compose)
import os

explicit_url = os.getenv("REDIS_URL")
if explicit_url:
return explicit_url

# Otherwise, build from components
if self.redis_password:
return (
f"redis://:{self.redis_password}@{self.redis_host}:"
f"{self.redis_port}/{self.redis_db}"
)
return f"redis://:{self.redis_password}@{self.redis_host}:{self.redis_port}/{self.redis_db}"
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"


Expand Down
8 changes: 0 additions & 8 deletions core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from core.app_state import AppState, DatabasePool, RedisClient
from core.auth import JWTAuth
from core.config_loader import ConfigLoader
from core.database import create_database_pool, verify_database_connection
from core.exceptions import (
AuthenticationError,
AuthorizationError,
Expand Down Expand Up @@ -51,7 +50,6 @@
UserContext,
UserStatus,
)
from core.redis import create_redis_client, verify_redis_connection
from core.telemetry import Metrics, TelemetryManager

__all__ = [
Expand All @@ -61,14 +59,8 @@
"RedisClient",
# Config
"ConfigLoader",
# Database
"create_database_pool",
"verify_database_connection",
# KeyVault
"KeyVaultClient",
# Redis
"create_redis_client",
"verify_redis_connection",
# Logger
"get_logger",
"setup_logging",
Expand Down
3 changes: 2 additions & 1 deletion core/app_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class AppState:
started_at: datetime = field(default_factory=lambda: datetime.now(UTC))
is_ready: bool = False
startup_errors: list[str] = field(default_factory=list)
pod_id: str | None = None # Pod identity for distributed connection management

def mark_ready(self) -> None:
"""Mark application as ready to accept traffic."""
Expand All @@ -85,7 +86,7 @@ def add_startup_error(self, error: str) -> None:
async def cleanup(self) -> None:
"""Cleanup all resources."""
if self.redis_client:
await self.redis_client.close()
await self.redis_client.disconnect()
if self.db_pool:
await self.db_pool.close()
if self.telemetry:
Expand Down
41 changes: 19 additions & 22 deletions core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,7 @@ async def get_refresh_token(self, token_hash: str) -> RefreshToken | None:
"""Get refresh token by hash."""
...

async def rotate_refresh_token(
self, old_token_id: UUID, new_token: RefreshToken
) -> None:
async def rotate_refresh_token(self, old_token_id: UUID, new_token: RefreshToken) -> None:
"""Rotate refresh token."""
...

Expand Down Expand Up @@ -140,10 +138,7 @@ def __init__(
# Private key for signing (if provided)
self.private_key = private_key

logger.info(
f"JWTAuth initialized with algorithm={algorithm}, "
f"access_ttl={access_token_ttl}s, refresh_ttl={refresh_token_ttl}s"
)
logger.info(f"JWTAuth initialized with algorithm={algorithm}, access_ttl={access_token_ttl}s, refresh_ttl={refresh_token_ttl}s")

async def validate_token(self, token: str) -> dict[str, Any]:
"""
Expand Down Expand Up @@ -273,17 +268,25 @@ async def extract_user_context(self, token: str) -> UserContext:
user_id=user_id,
)

# Cache with TTL
# Cache with TTL (match token expiration to avoid caching expired tokens)
if self.redis_client:
cache_key = f"user:context:{user_id}"
try:
import json

await self.redis_client.setex(
cache_key,
self.cache_ttl,
json.dumps(context.model_dump(), default=str),
)
# Calculate TTL: min of (token_exp - now) and cache_ttl
# This ensures cache doesn't expire after token, and doesn't exceed max cache TTL
expires_at = context.expires_at
now = datetime.now(UTC)
ttl_seconds = min(int((expires_at - now).total_seconds()), self.cache_ttl)

# Only cache if TTL is positive
if ttl_seconds > 0:
await self.redis_client.setex(
cache_key,
ttl_seconds,
json.dumps(context.model_dump(), default=str),
)
except Exception as e:
logger.warning(f"Failed to cache user context: {e}")

Expand Down Expand Up @@ -442,9 +445,7 @@ async def refresh_tokens(
)

# Generate new tokens
new_access_token, new_refresh_token = await self.generate_tokens(
user, ip_address=ip_address
)
new_access_token, new_refresh_token = await self.generate_tokens(user, ip_address=ip_address)

# Mark old token as rotated
new_refresh_token_hash = hashlib.sha256(new_refresh_token.encode()).hexdigest()
Expand All @@ -458,9 +459,7 @@ async def refresh_tokens(
rotated_at=datetime.now(UTC),
)

await self.postgres_client.rotate_refresh_token(
stored_token.token_id, new_refresh_token_model
)
await self.postgres_client.rotate_refresh_token(stored_token.token_id, new_refresh_token_model)

logger.info(
f"Refreshed tokens for user {user.user_id}",
Expand Down Expand Up @@ -608,9 +607,7 @@ async def logout(
except AuthenticationError:
# If token is invalid, still try to clean up if we have user_id
# This handles edge cases where token is expired but logout is called
logger.warning(
"Logout called with invalid token, cleanup may be incomplete"
)
logger.warning("Logout called with invalid token, cleanup may be incomplete")

def generate_trace_id(self) -> str:
"""
Expand Down
Loading
Loading