Compare commits

..

1 Commits

Author SHA1 Message Date
Barcode Betty 41a887a73b Fix SQLite server_default AttributeError and pool_size errors
CI / lint (pull_request) Failing after 3s
CI / typecheck (pull_request) Failing after 18s
CI / test (pull_request) Failing after 1m30s
CI / build-and-push (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
- Add hasattr(sd, 'expression') guard in engine fixtures to prevent
  AttributeError when iterating over server_default columns that use
  DefaultClause (which lacks .expression)
- Add _build_engine_kwargs() in database.py to conditionally apply
  pool_size/max_overflow only for non-SQLite database URLs
- Fixes test failures in conftest.py, test_encrypted_json.py

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-25 21:46:53 +00:00
10 changed files with 105 additions and 88 deletions
+87
View File
@@ -175,3 +175,90 @@ jobs:
git tag "v${{ steps.calver.outputs.version }}"
git push origin "v${{ steps.calver.outputs.version }}"
deploy-dev:
runs-on: ubuntu-latest
needs: [build-and-push]
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
steps:
- name: Checkout infra repo
uses: actions/checkout@v4
with:
repository: cartsnitch/infra
token: ${{ secrets.GITEA_TOKEN }}
ref: main
path: infra
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Determine image tag
id: api_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update api image tag
if: needs.build-and-push.result == 'success'
run: |
cd infra/apps/overlays/dev
kustomize edit set image ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.api_tag.outputs.tag }}
- name: Commit and push to infra
run: |
cd infra
git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/dev/kustomization.yaml
git commit -m "ci(dev): update api image"
git pull --rebase origin main
git push origin main
deploy-uat:
runs-on: ubuntu-latest
needs: [build-and-push]
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/uat' || github.ref == 'refs/heads/main')
steps:
- name: Checkout infra repo
uses: actions/checkout@v4
with:
repository: cartsnitch/infra
token: ${{ secrets.GITEA_TOKEN }}
ref: main
path: infra
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Determine image tag
id: api_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update api image tag
if: needs.build-and-push.result == 'success'
run: |
cd infra/apps/overlays/uat
kustomize edit set image ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.api_tag.outputs.tag }}
- name: Commit and push to infra
run: |
cd infra
git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/uat/kustomization.yaml
git commit -m "ci(uat): update api image"
git pull --rebase origin main
git push origin main
-11
View File
@@ -1,11 +0,0 @@
{
"mcpServers": {
"gitea": {
"type": "http",
"url": "https://git-mcp.farh.net/mcp",
"headers": {
"Authorization": "Bearer ${GITEA_TOKEN}"
}
}
}
}
+2 -2
View File
@@ -4,8 +4,8 @@ import bcrypt
def hash_password(password: str) -> str:
return str(bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode())
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bool(bcrypt.checkpw(plain_password.encode(), hashed_password.encode()))
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
+1 -6
View File
@@ -35,12 +35,7 @@ class CacheClient:
async def get(self, key: str) -> str | None:
if not self._client:
return None
value = await self._client.get(key)
if value is None:
return None
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return value
return await self._client.get(key)
async def set(self, key: str, value: str, ttl_seconds: int = 300) -> None:
if not self._client:
+1 -1
View File
@@ -86,4 +86,4 @@ class Settings(BaseSettings):
return self
settings = Settings() # type: ignore[call-arg]
settings = Settings()
-1
View File
@@ -14,7 +14,6 @@ def _build_engine_kwargs() -> dict:
kwargs.update(
pool_size=10,
max_overflow=20,
pool_timeout=30,
pool_pre_ping=True,
pool_recycle=3600,
)
+1 -9
View File
@@ -25,9 +25,6 @@ logger = logging.getLogger(__name__)
class RateLimitBackend(Protocol):
"""Protocol for rate limit backends."""
max_requests: int
window_seconds: int
async def is_allowed(self, key: str) -> tuple[bool, int, int]:
"""Check if request is allowed. Returns (allowed, remaining, retry_after)."""
@@ -85,8 +82,7 @@ class RedisSlidingWindow:
if current_count >= self.max_requests:
oldest = await self.redis.zrange(key, 0, 0, withscores=True)
if oldest:
oldest_score = float(oldest[0][1])
retry_after = int((oldest_score - cutoff) / 1000) + 1
retry_after = int((oldest[0][1] - cutoff) / 1000) + 1
else:
retry_after = self.window_seconds
return False, 0, retry_after
@@ -118,10 +114,6 @@ if settings.rate_limit_redis_enabled:
logger.warning("Failed to connect to Redis for rate limiting, using in-memory: %s", e)
_use_redis = False
_public_limiter: RateLimitBackend
_auth_limiter: RateLimitBackend
_auth_strict_limiter: RateLimitBackend
if _use_redis and _redis_client:
_public_limiter = RedisSlidingWindow(
_redis_client, settings.rate_limit_requests, settings.rate_limit_window_seconds
+3 -27
View File
@@ -1,40 +1,16 @@
"""Health check and error metrics endpoints."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends
from cartsnitch_api.auth.dependencies import verify_service_key
from cartsnitch_api.database import get_db
from cartsnitch_api.middleware.error_handler import get_error_monitor
logger = logging.getLogger(__name__)
router = APIRouter(tags=["health"])
@router.get("/health")
async def health(db: AsyncSession = Depends(get_db)):
"""Liveness + DB connectivity probe.
Returns HTTP 200 when the API process is responsive *and* the database
is reachable, so Kubernetes readiness probes can correctly route traffic
away from pods that have lost their database connection.
Returns HTTP 503 when the database is unreachable so K8s marks the pod
unhealthy and stops sending traffic to it.
"""
try:
await db.execute(text("SELECT 1"))
except Exception as exc:
logger.exception("Health check failed: database unreachable")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"status": "unavailable", "database": "disconnected"},
) from exc
return {"status": "ok", "database": "connected"}
async def health():
return {"status": "ok"}
@router.get("/internal/error-stats", dependencies=[Depends(verify_service_key)])
+8 -29
View File
@@ -19,15 +19,6 @@ from cartsnitch_api.database import get_db
from cartsnitch_api.main import create_app
from cartsnitch_api.models import Base
def _set_timestamp_defaults(mapper, connection, target):
"""Populate created_at/updated_at before insert for SQLite compatibility."""
now = datetime.now(UTC)
for col in [c for c in mapper.columns if c.key in ("created_at", "updated_at")]:
if getattr(target, col.key, None) is None:
setattr(target, col.key, now)
TEST_JWT_SECRET = secrets.token_urlsafe(32)
TEST_SERVICE_KEY = secrets.token_urlsafe(32)
TEST_FERNET_KEY = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
@@ -62,28 +53,22 @@ def disable_rate_limiting():
def engine():
"""Sync in-memory SQLite engine for model unit tests.
Strips PostgreSQL-specific server_default expressions and provides
Python-side defaults for SQLite compatibility.
Strips ALL PostgreSQL-specific server_default expressions so SQLite can
handle all column inserts without missing-function errors.
"""
eng = create_engine("sqlite:///:memory:")
for tbl in Base.metadata.tables.values():
for col in tbl.columns.values():
for table in Base.metadata.tables.values():
for col in table.columns.values():
sd = col.server_default
if sd is not None:
if not hasattr(sd, "expression"):
col.server_default = None
continue
expr_str = str(sd.expression).lower()
# Strip PostgreSQL-specific defaults
if any(x in expr_str for x in ["gen_random_uuid", "gen_random_bytes", "now()"]):
if "gen_random_uuid" in expr_str or "gen_random_bytes" in expr_str:
col.server_default = None
# Register event listener to populate timestamps on insert
for cls in Base.registry._class_registry.values():
if hasattr(cls, "__mapper__"):
event.listen(cls, "before_insert", _set_timestamp_defaults)
Base.metadata.create_all(eng)
yield eng
eng.dispose()
@@ -107,23 +92,17 @@ async def db_engine():
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
for tbl in Base.metadata.tables.values():
for col in tbl.columns.values():
for table in Base.metadata.tables.values():
for col in table.columns.values():
sd = col.server_default
if sd is not None:
if not hasattr(sd, "expression"):
col.server_default = None
continue
expr_str = str(sd.expression).lower()
# Strip PostgreSQL-specific defaults
if any(x in expr_str for x in ["gen_random_uuid", "gen_random_bytes", "now()"]):
if "gen_random_uuid" in expr_str or "gen_random_bytes" in expr_str:
col.server_default = None
# Register event listener to populate timestamps on insert
for cls in Base.registry._class_registry.values():
if hasattr(cls, "__mapper__"):
event.listen(cls, "before_insert", _set_timestamp_defaults)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.execute(
+2 -2
View File
@@ -18,8 +18,8 @@ from cartsnitch_api.models.user import User, UserStoreAccount
def engine():
eng = create_engine("sqlite:///:memory:")
for tbl in Base.metadata.tables.values():
for col in tbl.columns.values():
for table in Base.metadata.tables.values():
for col in table.columns.values():
sd = col.server_default
if sd is not None:
if not hasattr(sd, "expression"):