diff --git a/README.md b/README.md index 9472016..2880fd6 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,14 @@ This can be introduced incrementally after Phase 2 or alongside Phase 3 without - CI deploy writes that per-folder `.env` before running `docker compose --env-file /srv/throwback-/.env ...` on the VPS; reuse the same pattern for manual runs. - `REDIS_PASSWORD` is required for staging/prod; the dev `.env` ships with a default for local use only. The dev compose binds Redis to `127.0.0.1` and requires that password; avoid publishing Redis to the internet. - Auth config: set `AUTH_SECRET_KEY` (required for staging/prod) and optionally `ACCESS_TOKEN_TTL_MINUTES` (default 60). +- Rate limiting and quotas: tune `REQUEST_RATE_LIMIT_PER_MINUTE` (default 60) and `DAILY_UPLOAD_LIMIT_BYTES` (default 1 GB) as needed per environment. + +## Redis usage + +- Request throttling via `fastapi-limiter`: per-user (or IP if unauthenticated) caps using `REQUEST_RATE_LIMIT_PER_MINUTE`. +- Per-user daily upload quota using Redis counters keyed by date and user (`DAILY_UPLOAD_LIMIT_BYTES`). +- Short-lived caching for album/asset list responses. +- Celery broker and result backend. --- @@ -172,6 +180,7 @@ This can be introduced incrementally after Phase 2 or alongside Phase 3 without mode; schema bootstrap honors the configured `tbm` schema. - Upload pipeline: assets land in MinIO (local) or S3-compatible buckets with Celery tasks ready for metadata and variants. +- Basic protections: per-user/IP rate limiting (default 60 req/min) and per-user daily upload caps (default 1 GB) backed by Redis. - Auth baseline: JWT-based `/api/auth/signup` + `/api/auth/login` with bearer protection on `/api/albums` and `/api/assets`. - Local dev loop: `make db-up`, `make worker-up`, `make run`, `make migrate`. - Quality gates: `make fmt lint type test-single`. @@ -190,6 +199,7 @@ This can be introduced incrementally after Phase 2 or alongside Phase 3 without prefixes; optional schema-per-tenant for stricter separation. - **Delivery polish:** Document fly/railway deploy scripts, add image tags per branch, and provide a smoke-test checklist post-deploy. +- **Traffic shaping:** Tune rate limits/quotas per endpoint and surface counters/alerts in metrics. - **Security hardening (before user accounts/asset ACLs):** - Keep Redis/Postgres off public interfaces; require strong passwords and close host firewalls/SGs to 0.0.0.0 on 6379/5432. - Enforce HTTPS via proxy/CDN; set tight CORS and CSRF protections. diff --git a/app/api/deps.py b/app/api/deps.py index cdfced2..f66cb2f 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -2,7 +2,7 @@ from typing import Annotated -from fastapi import Depends, Header, HTTPException, status +from fastapi import Depends, Header, HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession from app.db import get_session @@ -11,6 +11,7 @@ async def get_current_user( + request: Request, authorization: Annotated[str | None, Header()] = None, db: AsyncSession = Depends(get_session), ) -> User: @@ -21,4 +22,7 @@ async def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) token = authorization.split(" ", 1)[1].strip() - return await AuthService(db).validate_token(token) + user = await AuthService(db).validate_token(token) + request.state.user_id = user.id + request.state.user_email = user.email + return user diff --git a/app/api/routers/assets.py b/app/api/routers/assets.py index cf8e761..2f0cb5a 100644 --- a/app/api/routers/assets.py +++ b/app/api/routers/assets.py @@ -5,8 +5,10 @@ from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile from sqlalchemy.ext.asyncio import AsyncSession +from app.api import deps from app.constants import API_PREFIX from app.db import get_session +from app.models.users import User from app.schemas.assets import AssetOut, AssetVariantOut, MediaType from app.services.assets import AssetService from app.settings import settings @@ -20,11 +22,12 @@ async def upload_asset( media_type: Annotated[MediaType, Form(...)], file: Annotated[UploadFile, File(...)], response: Response, + current_user: User = Depends(deps.get_current_user), db: AsyncSession = Depends(get_session), ) -> AssetOut: svc = AssetService(db) out = await svc.create_from_upload( - album_id=album_id, media_type=media_type, file=file + album_id=album_id, media_type=media_type, file=file, user_id=current_user.id ) response.headers["Location"] = settings.api_url( f"{API_PREFIX}/assets/{out.id}/variants" diff --git a/app/main.py b/app/main.py index 500e4b4..a02b746 100644 --- a/app/main.py +++ b/app/main.py @@ -1,8 +1,10 @@ from fastapi import APIRouter, Depends, FastAPI +from fastapi_limiter.depends import RateLimiter from app.api import deps from app.api.routers import albums, assets, auth, health from app.constants import API_PREFIX +from app.rate_limiter import close_rate_limiter, init_rate_limiter from app.settings import settings servers = [{"url": settings.app_base_url}] if settings.app_base_url else None @@ -15,14 +17,44 @@ ) public_api = APIRouter(prefix=API_PREFIX) -public_api.include_router(auth.router) +public_api.include_router( + auth.router, + dependencies=[ + Depends( + RateLimiter( + times=settings.request_rate_limit_per_minute, + seconds=60, + ) + ) + ], +) public_api.include_router(health.router) protected_api = APIRouter( - prefix=API_PREFIX, dependencies=[Depends(deps.get_current_user)] + prefix=API_PREFIX, + dependencies=[ + Depends(deps.get_current_user), + Depends( + RateLimiter( + times=settings.request_rate_limit_per_minute, + seconds=60, + ) + ), + ], ) protected_api.include_router(albums.router) protected_api.include_router(assets.router) app.include_router(public_api) app.include_router(protected_api) + + +@app.on_event("startup") +async def _startup() -> None: + redis_client = getattr(app.state, "rate_limit_redis", None) + await init_rate_limiter(redis_client) + + +@app.on_event("shutdown") +async def _shutdown() -> None: + await close_rate_limiter() diff --git a/app/rate_limiter.py b/app/rate_limiter.py new file mode 100644 index 0000000..b75219e --- /dev/null +++ b/app/rate_limiter.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import redis.asyncio as aioredis +from fastapi import Request +from fastapi_limiter import FastAPILimiter + +from app.settings import settings + + +async def rate_limit_identifier(request: Request) -> str: + """ + Prefer user id when available (set in get_current_user), otherwise fall + back to client IP. Keeps limits per-authenticated user across hosts. + """ + user_id = getattr(request.state, "user_id", None) + if user_id: + return f"user:{user_id}" + if request.client and request.client.host: + return f"ip:{request.client.host}" + return "anonymous" + + +async def init_rate_limiter(redis_client: aioredis.Redis | None = None) -> None: + """ + Initialize FastAPI-Limiter with a Redis client. Uses decode_responses to + keep counters as strings for portability. + """ + redis = redis_client or aioredis.from_url( + settings.redis_url, encoding="utf-8", decode_responses=True + ) + await FastAPILimiter.init(redis, identifier=rate_limit_identifier) + + +async def close_rate_limiter() -> None: + try: + await FastAPILimiter.close() + except Exception: + # best effort; don't crash shutdown + return diff --git a/app/services/assets.py b/app/services/assets.py index 45ee849..3786642 100644 --- a/app/services/assets.py +++ b/app/services/assets.py @@ -6,6 +6,7 @@ import mimetypes import subprocess import tempfile +from datetime import datetime, time, timedelta, timezone from pathlib import Path from typing import Iterable, cast @@ -93,7 +94,12 @@ def __init__(self, db: AsyncSession) -> None: self.repo = AssetRepository(db) async def create_from_upload( - self, *, album_id: int, media_type: MediaType, file: UploadFile + self, + *, + album_id: int, + media_type: MediaType, + file: UploadFile, + user_id: int | None = None, ) -> AssetOut: # 1) validate album exists album = await self.db.get(Album, album_id) @@ -107,56 +113,77 @@ async def create_from_upload( if not data: raise HTTPException(status_code=400, detail="Empty upload") + quota_bytes = len(data) + quota_charged = False + + if user_id is not None: + await self._enforce_daily_upload_quota( + user_id=user_id, bytes_added=quota_bytes + ) + quota_charged = True + # 3) persist original to storage ext = _ext_for(file.filename, file.content_type) storage_key = storage_key_for( asset_id=0, kind="original_tmp", ext=ext ) # placeholder; we’ll rename after insert - await write_bytes_async( - storage_key, - data, - content_type=file.content_type or "application/octet-stream", - ) - - # 3b) derive metadata - width: int | None = None - height: int | None = None - duration: int | None = None - if media_type == "photo": - width, height = _photo_dimensions(data) - elif media_type == "video": - width, height, duration = _video_metadata(data) - - # 4) checksum for dedupe/idempotency (optional use) - checksum = sha256_bytes([data]) - - # 5) insert DB row (we’ll rename the storage key with the new asset id) - asset = await self.repo.create( - album_id=album_id, - media_type=media_type, - storage_key=storage_key, - mime_type=file.content_type or "application/octet-stream", - size_bytes=len(data), - original_filename=file.filename, - checksum_sha256=checksum, - ) - asset.width = width - asset.height = height - asset.duration_sec = duration - # rename key to include real id (optional but nice) - final_key = storage_key_for(asset_id=asset.id, kind="original", ext=ext) - if final_key != storage_key: - await copy_bytes_async( - src_key=storage_key, - dst_key=final_key, + try: + await write_bytes_async( + storage_key, + data, content_type=file.content_type or "application/octet-stream", ) - storage_key = final_key - asset.storage_key = storage_key - await self.db.commit() - await self.db.refresh(asset) + # 3b) derive metadata + width: int | None = None + height: int | None = None + duration: int | None = None + if media_type == "photo": + width, height = _photo_dimensions(data) + elif media_type == "video": + width, height, duration = _video_metadata(data) + + # 4) checksum for dedupe/idempotency (optional use) + checksum = sha256_bytes([data]) + + # 5) insert DB row (we’ll rename the storage key with the new asset id) + asset = await self.repo.create( + album_id=album_id, + media_type=media_type, + storage_key=storage_key, + mime_type=file.content_type or "application/octet-stream", + size_bytes=len(data), + original_filename=file.filename, + checksum_sha256=checksum, + ) + asset.width = width + asset.height = height + asset.duration_sec = duration + # rename key to include real id (optional but nice) + final_key = storage_key_for(asset_id=asset.id, kind="original", ext=ext) + if final_key != storage_key: + await copy_bytes_async( + src_key=storage_key, + dst_key=final_key, + content_type=file.content_type or "application/octet-stream", + ) + storage_key = final_key + + asset.storage_key = storage_key + await self.db.commit() + await self.db.refresh(asset) + except Exception as exc: + if quota_charged and user_id is not None: + await self._rollback_daily_upload_quota( + user_id=user_id, bytes_removed=quota_bytes + ) + if isinstance(exc, HTTPException): + raise + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Upload failed, please retry", + ) from exc # 6) bump album cache namespace and enqueue processing await bump(f"album:{album_id}") @@ -264,3 +291,54 @@ async def list_variants(self, asset_id: int) -> list[AssetVariantOut]: ) for v in row.variants ] + + async def _enforce_daily_upload_quota( + self, *, user_id: int, bytes_added: int + ) -> None: + """ + Enforce per-user daily upload quota using Redis counters. We increment + optimistically then roll back if the cap is exceeded. + """ + key = self._upload_quota_key(user_id=user_id) + ttl = self._seconds_until_tomorrow() + + from app.cache.redis import get_redis_async + + r = get_redis_async() + current = await r.incrby(key, bytes_added) + # set TTL only when first created + if current == bytes_added: + await r.expire(key, ttl) + + if current > settings.daily_upload_limit_bytes: + await r.incrby(key, -bytes_added) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Daily upload limit exceeded", + ) + + async def _rollback_daily_upload_quota( + self, *, user_id: int, bytes_removed: int + ) -> None: + """ + Best-effort rollback to keep the counter accurate when downstream work fails. + """ + key = self._upload_quota_key(user_id=user_id) + + from app.cache.redis import get_redis_async + + r = get_redis_async() + new_value = await r.incrby(key, -bytes_removed) + if new_value <= 0: + await r.delete(key) + + def _upload_quota_key(self, *, user_id: int) -> str: + today = datetime.now(timezone.utc).date().isoformat() + return f"user:{user_id}:upload_bytes:{today}" + + def _seconds_until_tomorrow(self) -> int: + now = datetime.now(timezone.utc) + tomorrow = datetime.combine( + now.date() + timedelta(days=1), time(0, 0, tzinfo=timezone.utc) + ) + return int((tomorrow - now).total_seconds()) diff --git a/app/settings.py b/app/settings.py index 0555619..420a7e2 100644 --- a/app/settings.py +++ b/app/settings.py @@ -65,6 +65,12 @@ class Settings(BaseSettings): media_base_url: str | None = None jwt_secret: str = Field(default="dev-secret-change-me", alias="AUTH_SECRET_KEY") access_token_ttl_minutes: int = Field(default=60, alias="ACCESS_TOKEN_TTL_MINUTES") + request_rate_limit_per_minute: int = Field( + default=60, alias="REQUEST_RATE_LIMIT_PER_MINUTE" + ) + daily_upload_limit_bytes: int = Field( + default=1_000_000_000, alias="DAILY_UPLOAD_LIMIT_BYTES" + ) def model_post_init(self, __context: object) -> None: # Compute URLs from components when not explicitly provided. diff --git a/poetry.lock b/poetry.lock index 775022d..2289d77 100644 --- a/poetry.lock +++ b/poetry.lock @@ -619,6 +619,30 @@ files = [ [package.extras] testing = ["hatch", "pre-commit", "pytest", "tox"] +[[package]] +name = "fakeredis" +version = "2.32.1" +description = "Python implementation of redis API, can be used for testing purposes." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "fakeredis-2.32.1-py3-none-any.whl", hash = "sha256:e80c8886db2e47ba784f7dfe66aad6cd2eab76093c6bfda50041e5bc890d46cf"}, + {file = "fakeredis-2.32.1.tar.gz", hash = "sha256:dd8246db159f0b66a1ced7800c9d5ef07769e3d2fde44b389a57f2ce2834e444"}, +] + +[package.dependencies] +redis = {version = ">=4.3", markers = "python_version > \"3.8\""} +sortedcontainers = ">=2" + +[package.extras] +bf = ["pyprobables (>=0.6)"] +cf = ["pyprobables (>=0.6)"] +json = ["jsonpath-ng (>=1.6)"] +lua = ["lupa (>=2.1)"] +probabilistic = ["pyprobables (>=0.6)"] +valkey = ["valkey (>=6) ; python_version >= \"3.8\""] + [[package]] name = "fastapi" version = "0.120.4" @@ -642,6 +666,22 @@ all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (> standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +[[package]] +name = "fastapi-limiter" +version = "0.1.6" +description = "A request rate limiter for fastapi" +optional = false +python-versions = ">=3.9,<4.0" +groups = ["main"] +files = [ + {file = "fastapi_limiter-0.1.6-py3-none-any.whl", hash = "sha256:2e53179a4208b8f2c8795e38bb001324d3dc37d2800ff49fd28ec5caabf7a240"}, + {file = "fastapi_limiter-0.1.6.tar.gz", hash = "sha256:6f5fde8efebe12eb33861bdffb91009f699369a3c2862cdc7c1d9acf912ff443"}, +] + +[package.dependencies] +fastapi = "*" +redis = ">=4.2.0rc1" + [[package]] name = "greenlet" version = "3.2.4" @@ -1889,7 +1929,7 @@ version = "5.2.1" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4"}, {file = "redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f"}, @@ -1969,6 +2009,18 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + [[package]] name = "sqlalchemy" version = "2.0.44" @@ -2534,4 +2586,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "c695eece14c5e82d7de6702582e03b88332de05150da375de6a31c8b24395dd8" +content-hash = "a159205fc29092b36c9a9f6a0996dcd9e5574e94d0150e821cd87543160a9f51" diff --git a/pyproject.toml b/pyproject.toml index 54deafb..fb32d39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ orjson = ">=3.10" Pillow = ">=10.4" python-dotenv = ">=1.2.1,<2.0.0" email-validator = "^2.3.0" +fastapi-limiter = "^0.1.6" [tool.poetry.group.dev.dependencies] pytest = ">=8.4.2,<9.0.0" @@ -38,6 +39,7 @@ isort = ">=5.13.2,<6.0.0" ruff = ">=0.6.8,<0.7.0" types-redis = ">=4.6.0.20241004,<5.0.0.0" types-requests = ">=2.32.4.20250913,<3.0.0.0" +fakeredis = "^2.32.1" [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] diff --git a/tests/conftest.py b/tests/conftest.py index 8c054b1..fba699a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import asyncio import os import re +import time from typing import AsyncGenerator, Generator, Iterable import pytest @@ -183,6 +184,17 @@ async def incr(self, key: str) -> int: self.store[key] = str(new_val).encode() return new_val + async def incrby(self, key: str, amount: int) -> int: + raw = self.store.get(key, b"0") or b"0" + current = int(raw) + new_val = current + amount + self.store[key] = str(new_val).encode() + return new_val + + async def expire(self, key: str, ttl: int) -> bool: + # No TTL tracking in memory stub; pretend success. + return True + async def delete(self, key: str) -> None: self.store.pop(key, None) @@ -209,10 +221,51 @@ def incr(self, key: str) -> int: self.store[key] = str(new_val).encode() return new_val + def incrby(self, key: str, amount: int) -> int: + raw = self.store.get(key, b"0") or b"0" + current = int(raw) + new_val = current + amount + self.store[key] = str(new_val).encode() + return new_val + + def expire(self, key: str, ttl: int) -> bool: + return True + def delete(self, key: str) -> None: self.store.pop(key, None) +class _DummyRateLimitRedis: + """ + Minimal Redis stub that supports the fastapi-limiter script interface. + Counts requests per key within the provided window (ms). + """ + + def __init__(self) -> None: + self.window: dict[str, tuple[int, float]] = {} + + async def script_load(self, script: str) -> str: + return "dummy-sha" + + async def evalsha( + self, sha: str, num_keys: int, key: str, limit: str, expire_ms: str + ) -> int: + max_calls = int(limit) + window_ms = int(expire_ms) + now = time.monotonic() * 1000 + count, reset_at = self.window.get(key, (0, now + window_ms)) + if now > reset_at: + count, reset_at = 0, now + window_ms + if count >= max_calls: + return max(1, int(reset_at - now)) + count += 1 + self.window[key] = (count, reset_at) + return 0 + + async def close(self) -> None: + return None + + @pytest.fixture(autouse=True) def fake_redis(monkeypatch: pytest.MonkeyPatch) -> dict[str, bytes]: store: dict[str, bytes] = {} @@ -303,14 +356,19 @@ async def _override_get_session() -> AsyncGenerator[AsyncSession, None]: yield session fastapi_app.dependency_overrides[get_session] = _override_get_session + # Provide an in-memory Redis for rate limiting during tests. + fastapi_app.state.rate_limit_redis = _DummyRateLimitRedis() + await fastapi_app.router.startup() async with AsyncClient( - transport=ASGITransport(app=fastapi_app), base_url="http://testserver/api" + transport=ASGITransport(app=fastapi_app), + base_url="http://testserver/api", ) as ac: try: yield ac finally: fastapi_app.dependency_overrides.clear() + await fastapi_app.router.shutdown() @pytest_asyncio.fixture diff --git a/tests/test_assets.py b/tests/test_assets.py index 02f744e..133e937 100644 --- a/tests/test_assets.py +++ b/tests/test_assets.py @@ -6,9 +6,11 @@ import pytest from fastapi import UploadFile from httpx import AsyncClient +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from starlette.datastructures import Headers +from app.models.users import User from app.services.assets import AssetService from tests.utils import make_ffmpeg_mp4_bytes, make_image_bytes @@ -37,6 +39,66 @@ async def test_upload_rejects_empty_file(client: AsyncClient) -> None: assert resp.status_code == 400 +@pytest.mark.asyncio +async def test_upload_quota_rolls_back_on_failure( + client: AsyncClient, + db_session: AsyncSession, + fake_redis: dict[str, bytes], + monkeypatch: pytest.MonkeyPatch, +) -> None: + album = await client.post("/albums", json={"title": "Rollback"}) + album_id = album.json()["id"] + + async def boom(*args: object, **kwargs: object) -> None: # pragma: no cover + raise RuntimeError("storage failed") + + monkeypatch.setattr("app.services.assets.write_bytes_async", boom) + + payload = b"x" * 128 + resp = await client.post( + "/assets/upload", + data={"album_id": album_id, "media_type": "photo"}, + files={"file": ("boom.jpg", payload, "image/jpeg")}, + ) + + assert resp.status_code == 500 + + user_row = ( + (await db_session.execute(select(User).order_by(User.id))).scalars().first() + ) + assert user_row is not None + + quota_key = AssetService(db_session)._upload_quota_key(user_id=user_row.id) + assert quota_key not in fake_redis or fake_redis.get(quota_key) in {b"0", None} + + +@pytest.mark.asyncio +async def test_upload_respects_daily_quota( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: + from app.settings import settings as settings_mod + + monkeypatch.setattr(settings_mod, "daily_upload_limit_bytes", 300) + + album = await client.post("/albums", json={"title": "Quota"}) + album_id = album.json()["id"] + + payload = b"x" * 200 + resp1 = await client.post( + "/assets/upload", + data={"album_id": album_id, "media_type": "photo"}, + files={"file": ("a.jpg", payload, "image/jpeg")}, + ) + assert resp1.status_code == 200 + + resp2 = await client.post( + "/assets/upload", + data={"album_id": album_id, "media_type": "photo"}, + files={"file": ("b.jpg", payload, "image/jpeg")}, + ) + assert resp2.status_code == 429 + + @pytest.mark.asyncio async def test_list_assets_sorted_desc( client: AsyncClient, db_session: AsyncSession