2b20946ad7
QA review of PR #39 (CAR-1121) identified three blocking issues; this commit addresses all three plus the typecheck errors flagged as CI RED. CAR-1077 (PR #39) changes: - database.py: add pool_timeout=30 so the engine fails fast when the connection pool is exhausted (defends against the "server closed connection unexpectedly" pod failures). - routes/health.py: /health now calls SELECT 1 through Depends(get_db) and raises HTTPException(503) when the database is unreachable, so Kubernetes readiness probes can correctly mark the pod unhealthy and stop routing traffic to it. Logs the failure at exception level for observability. - Drop .mcp.json from this PR (root-level MCP server config, not related to the pool fix; tracked separately). CI typecheck fixes (pre-existing on dev, were failing mypy on PR #39): - auth/passwords.py: cast bcrypt return values so mypy doesn't widen to Any. - config.py: silence the false-positive call-arg on Settings() — the three required fields are populated from the environment by pydantic-settings at runtime. - cache.py: coerce the bytes/str union returned by the redis client to the documented str | None return type. - middleware/rate_limit.py: annotate the three module-level limiters with the RateLimitBackend protocol, cast the redis zrange score to float before arithmetic, and add max_requests/window_seconds to the protocol so the response-header builder can read them. Co-Authored-By: Paperclip <noreply@paperclip.ing>
90 lines
3.3 KiB
Python
90 lines
3.3 KiB
Python
import base64
|
|
|
|
from pydantic import AliasChoices, Field, model_validator
|
|
from pydantic_settings import BaseSettings
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = {"env_prefix": "CARTSNITCH_"}
|
|
|
|
database_url: str = Field(
|
|
default="postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
|
|
validation_alias=AliasChoices("CARTSNITCH_DATABASE_URL", "DATABASE_URL"),
|
|
)
|
|
redis_url: str = "redis://localhost:6379/0"
|
|
|
|
jwt_secret_key: str
|
|
jwt_algorithm: str = "HS256"
|
|
jwt_access_token_expire_minutes: int = 15
|
|
jwt_refresh_token_expire_days: int = 7
|
|
|
|
service_key: str
|
|
fernet_key: str
|
|
|
|
auth_service_url: str = "http://auth:3001"
|
|
|
|
cors_origins: list[str] = [
|
|
"http://localhost:3000",
|
|
"https://cartsnitch.com",
|
|
"https://dev.cartsnitch.com",
|
|
"https://uat.cartsnitch.com",
|
|
]
|
|
|
|
receiptwitness_url: str = "http://receiptwitness:8001"
|
|
stickershock_url: str = "http://stickershock:8002"
|
|
clipartist_url: str = "http://clipartist:8003"
|
|
shrinkray_url: str = "http://shrinkray:8004"
|
|
|
|
rate_limit_requests: int = 60
|
|
rate_limit_window_seconds: int = 60
|
|
rate_limit_auth_requests: int = 5
|
|
rate_limit_auth_window_seconds: int = 60
|
|
rate_limit_redis_enabled: bool = True
|
|
rate_limit_enabled: bool = True
|
|
|
|
_PLACEHOLDER_VALUES = {"change-me-in-production"}
|
|
|
|
@model_validator(mode="after")
|
|
def validate_secrets(self):
|
|
if not self.jwt_secret_key or self.jwt_secret_key in self._PLACEHOLDER_VALUES:
|
|
raise ValueError(
|
|
"CARTSNITCH_JWT_SECRET_KEY must be set to a secure value. "
|
|
'Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
|
)
|
|
if not self.service_key or self.service_key in self._PLACEHOLDER_VALUES:
|
|
raise ValueError(
|
|
"CARTSNITCH_SERVICE_KEY must be set to a secure value. "
|
|
'Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
|
)
|
|
if not self.fernet_key or self.fernet_key in self._PLACEHOLDER_VALUES:
|
|
raise ValueError(
|
|
"CARTSNITCH_FERNET_KEY must be set to a valid Fernet key. "
|
|
"Generate one with: python -c "
|
|
"'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'"
|
|
)
|
|
try:
|
|
decoded = base64.urlsafe_b64decode(self.fernet_key.encode())
|
|
if len(decoded) != 32:
|
|
raise ValueError
|
|
except Exception:
|
|
raise ValueError(
|
|
"CARTSNITCH_FERNET_KEY must be a valid Fernet key "
|
|
"(32 bytes, url-safe base64 encoded). "
|
|
"Generate one with: python -c "
|
|
"'from cryptography.fernet import Fernet; "
|
|
"print(Fernet.generate_key().decode())'"
|
|
) from None
|
|
return self
|
|
|
|
@model_validator(mode="after")
|
|
def normalize_database_url(self):
|
|
"""Normalize postgresql:// → postgresql+asyncpg:// for the asyncpg driver."""
|
|
if self.database_url.startswith("postgresql://"):
|
|
self.database_url = self.database_url.replace(
|
|
"postgresql://", "postgresql+asyncpg://", 1
|
|
)
|
|
return self
|
|
|
|
|
|
settings = Settings() # type: ignore[call-arg]
|