# Story 1.6: Middleware Rate Limiting par Tier

Status: done

<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->

## Story

As a **system**,
I want **to enforce daily translation quotas based on user tier**,
so that **free users are limited and system resources are protected**.

## Acceptance Criteria

1. **AC1: Quota Free dépassé** — Given a user with tier="free" has translated 5 files today, when they attempt another translation, then they receive HTTP 429 with body `{"error": "QUOTA_EXCEEDED", "message": "..."}` and header `Retry-After` (seconds until midnight UTC).
2. **AC2: Pro sans limite** — User with tier="pro" has no daily limit (unlimited translations).
3. **AC3: Reset à minuit UTC** — daily_translation_count (or equivalent sliding-window counter) resets at midnight UTC.
4. **AC4: Redis + sliding window** — Rate limiting uses Redis with a sliding-window algorithm (per user, per day for free tier).
5. **AC5: meta.rate_limit_remaining** — Successful translation responses (and optionally other authenticated responses) include `meta.rate_limit_remaining` (integer: remaining files for the current day for free; -1 or omit for pro).

## Tasks / Subtasks

- [x] **Task 1: Tier-aware rate limit service** (AC: 1, 2, 3, 4)
  - [x] 1.1 Introduce or reuse Redis client (connection from config/env). If Redis unavailable, fallback to in-memory with clear doc/comment.
  - [x] 1.2 Implement sliding-window daily counter per user_id in Redis (key pattern e.g. `rate_limit:daily:{user_id}`, window = day in UTC).
  - [x] 1.3 For free tier: limit 5 translations per day; for pro (and equivalent) tier: no daily cap.
  - [x] 1.4 Expose function to check quota (allowed, remaining, reset_at_utc) and to increment on successful translation.
- [x] **Task 2: Middleware / dependency integration** (AC: 1, 5)
  - [x] 2.1 Apply tier-based quota check on translation endpoint(s): resolve current user (JWT or API key), get tier, then check/increment quota.
  - [x] 2.2 On quota exceeded: return 429, error code "QUOTA_EXCEEDED", message user-friendly, header Retry-After = seconds until next midnight UTC.
  - [x] 2.3 On success: include `meta.rate_limit_remaining` (and optionally `meta.rate_limit_reset_at` ISO8601) in response.
- [x] **Task 3: daily_translation_count alignment** (AC: 3)
  - [x] 3.1 Keep User.daily_translation_count in sync with Redis (or use as fallback if no Redis): increment on successful translation; reset at midnight UTC (job or on-read with date check).
  - [x] 3.2 Prefer single source of truth: either Redis-only for quota enforcement with DB as cache, or DB-only with Redis as cache; document choice.
- [x] **Task 4: Tests** (AC: 1–5)
  - [x] 4.1 Test: free user at 5 translations → next request returns 429 QUOTA_EXCEEDED and Retry-After.
  - [x] 4.2 Test: pro user can translate beyond 5 without 429.
  - [x] 4.3 Test: response payload includes meta.rate_limit_remaining for free user.
  - [x] 4.4 Test: after midnight UTC (or mocked reset), free user can translate again.
  - [x] 4.5 Test: unauthenticated translation request handled (401 or existing behavior; quota not applied without user).

## Dev Notes

- Le projet a déjà un **rate limiting par IP** dans `middleware/rate_limiting.py` (SlidingWindowCounter, TokenBucket, par client_id = IP). La story 1.6 ajoute un **quota par tier utilisateur** (free 5/jour, pro illimité) avec Redis et sliding window, sans nécessairement supprimer le rate limit par IP (à conserver ou documenter la coexistence).
- **Modèle User** : `database/models.py` a déjà `tier` et `daily_translation_count`. Migration `002_add_tier_daily_count.py` existe. Aligner la logique de quota avec ces champs (sync ou source of truth).
- **Auth** : Les routes de traduction doivent résoudre l’utilisateur courant (JWT via `get_current_user` ou équivalent, ou X-API-Key pour API). Voir `routes/auth_routes.py` et dépendances auth existantes.
- **Endpoints concernés** : Tout endpoint qui déclenche une traduction (ex. POST upload/translate). Dans `main.py` la vérification actuelle est `rate_limit_manager.check_translation_limit(client_ip)` — à remplacer ou compléter par un check par user/tier.

### Architecture Compliance

- **Format erreur 429** — [Source: architecture.md]
  ```json
  {
    "error": "QUOTA_EXCEEDED",
    "message": "Limite quotidienne atteinte (5/5 fichiers). Réessayez après minuit UTC.",
    "details": {
      "current_usage": 5,
      "limit": 5,
      "tier": "free",
      "reset_at": "2024-01-16T00:00:00Z"
    }
  }
  ```
- **Header** : `Retry-After: <seconds>` (recommandé en secondes jusqu’à minuit UTC).
- **Format succès avec meta** — [Source: architecture.md]
  ```json
  {
    "data": { ... },
    "meta": {
      "rate_limit_remaining": 4,
      "rate_limit_reset_at": "2024-01-16T00:00:00Z"
    }
  }
  ```
  Pour Pro, `rate_limit_remaining` peut être `null`, `-1`, ou omis.

### Fichiers à modifier / créer

| Fichier | Action |
|---------|--------|
| `middleware/rate_limiting.py` ou nouveau `middleware/tier_quota.py` | Ajouter logique quota par user/tier (Redis sliding window + limite 5 free). |
| `main.py` (ou route translate) | Remplacer/compléter `check_translation_limit(client_ip)` par check quota user + retour 429 QUOTA_EXCEEDED + Retry-After; ajouter meta.rate_limit_remaining en succès. |
| `database/models.py` / `database/utils.py` | Si on garde daily_translation_count comme reflet: incrément + reset minuit UTC (ou délégation au service Redis). |
| `tests/test_tier_rate_limit.py` (ou équivalent) | Nouveaux tests AC1–AC5. |
| Config / `.env.example` | REDIS_URL si pas déjà présent pour rate limiting. |

### Fichiers à ne pas casser

- `routes/auth_routes.py`, `services/auth_service.py` — Pas de changement requis sauf utilisation de `get_current_user` / user dans la route translate.
- Comportement existant du rate limit par IP (à conserver ou documenter comme couche supplémentaire).

### Références

- [Source: _bmad-output/planning-artifacts/epics.md#Story 1.6]
- [Source: _bmad-output/planning-artifacts/architecture.md#API Response Formats]
- [Source: _bmad-output/planning-artifacts/architecture.md#Data Boundaries - Rate Limiting Logic]
- [Source: _bmad-output/planning-artifacts/prd.md#NFR20 - Rate limiting]
- [Source: _bmad-output/implementation-artifacts/1-5-refresh-token.md — Patterns auth, format data/meta]

---

## Developer Context (Guardrails)

### Technical requirements

- **Backend** : FastAPI, Python 3.11+. Rate limiting par **user** (identifié par JWT ou API key), pas seulement par IP.
- **Redis** : Utiliser Redis pour sliding-window quotidien par user (clé type `rate_limit:daily:{user_id}`). Si Redis absent, fallback in-memory documenté.
- **Tier** : `free` → 5 fichiers/jour; `pro` (et équivalents selon modèle) → pas de limite quotidienne.
- **Réponses** : 429 avec `error: "QUOTA_EXCEEDED"`, `message` en français, `details` optionnel (usage, limit, reset_at). Header `Retry-After` en secondes. Succès avec `meta.rate_limit_remaining` (et optionnellement `rate_limit_reset_at`).

### Architecture compliance

- Conventions API : JSON snake_case, format erreur sans `data`, format succès avec `data` + `meta`.
- NFR20 : Rate limiting par utilisateur, réponse 429 avec Retry-After.

### Library / framework

- **Redis** : Client async recommandé (e.g. `redis.asyncio` ou `aioredis`) pour ne pas bloquer l’event loop.
- **FastAPI** : Dépendance `get_current_user` (ou équivalent) sur la route de traduction pour obtenir user_id et tier.

### File structure

- Middleware ou service : `middleware/rate_limiting.py` (étendre) ou `middleware/tier_quota.py` (nouveau) + possible `services/quota_service.py` si logique lourde.
- Config : REDIS_URL dans config/env ; constante FREE_TIER_DAILY_LIMIT = 5.

### Testing requirements

- Tests d’intégration ou unitaires : free user 5ème traduction OK, 6ème → 429 QUOTA_EXCEEDED, Retry-After présent ; pro user au-delà de 5 → 200 avec meta ; vérification de meta.rate_limit_remaining ; reset après minuit UTC (mock ou time freeze).

---

## Previous Story Intelligence (1-5 Refresh Token)

- **Fichiers modifiés** : `routes/auth_routes.py` (endpoint refresh v1), `tests/test_auth_refresh.py`. Auth service inchangé (verify_token, create_access_token, create_refresh_token).
- **Patterns** : Réponses 200 avec `{"data": {...}, "meta": {}}`, 401 avec `TOKEN_EXPIRED`, 400 avec `INVALID_REQUEST`. Pour la story 1.6, réutiliser le même style de réponses et s’appuyer sur l’utilisateur résolu via JWT (ou API key) pour obtenir tier et user_id.
- **Tests** : Fixtures utilisateur/tokens dans tests auth ; pour 1.6, ajouter fixtures free vs pro user et mocker Redis si besoin.

---

## Git Intelligence Summary

- Derniers commits : Docker/PostgreSQL, frontend/admin, providers (OpenRouter, DeepSeek), corrections admin login. Aucun commit récent sur rate limiting par tier.
- Codebase actuelle : rate limiting par IP dans `middleware/rate_limiting.py` et `main.py` ; modèle User avec `tier` et `daily_translation_count` déjà présents. Implémentation 1.6 doit s’intégrer sans supprimer le rate limit IP sauf décision explicite (documenter coexistence ou remplacement).

---

## Latest Tech Information

- **Redis** : Utiliser une fenêtre glissante (sliding window) en Redis : soit INCR + EXPIRE avec clé par jour (rollover à minuit UTC), soit sorted set (ZADD avec timestamp, ZREMRANGEBYSCORE pour fenêtre 24h). Retry-After = secondes jusqu’à minuit UTC (calcul avec datetime en UTC).
- **FastAPI** : Pour injecter `meta` dans toutes les réponses succès d’un sous-ensemble de routes, on peut utiliser un middleware de réponse ou une dépendance qui enrichit le corps ; pour la traduction, enrichir directement la réponse de l’endpoint est suffisant.

---

## Project Context Reference

- **Structure** : Backend à la racine : `main.py`, `routes/`, `services/`, `database/`, `middleware/`, `tests/`. Auth v1 sous `routes/auth_routes.py` avec prefix `/api/v1/auth`. Route de traduction (upload) dans `main.py` ; vérification rate limit actuelle par IP avant traitement.
- **Montage** : Middleware existant `RateLimitMiddleware` appliqué à toute l’app ; le quota par tier peut être appliqué au niveau de la route translate (après auth) plutôt que dans un middleware global, pour avoir accès au user.

---

## Story Completion Status

- **Status** : done
- **Note** : Implementation complete; tier quota (Redis + in-memory fallback), /translate integration, daily_translation_count sync, tests AC1–AC5. Code review 2026-02-20: tests 4.2/4.4 added, auth_update_user in thread pool, File List completed.

## Change Log

- 2026-02-20: Story 1.6 implemented — middleware/tier_quota.py (Redis + in-memory), /translate tier quota check and 429 QUOTA_EXCEEDED, X-Rate-Limit-Remaining/Reset-At headers, daily_translation_count sync, 12 tests (unit + integration).
- 2026-02-20: Code review fixes — added test_pro_user_beyond_five_no_429 (Task 4.2), test_translate_free_user_after_reset_can_translate_again (Task 4.4); main.py auth_update_user run in asyncio.to_thread; File List completed with alembic/database/routes/requirements.

## Dev Agent Record

### Agent Model Used

{{agent_model_name_version}}

### Debug Log References

### Completion Notes List

- TierQuotaService in `middleware/tier_quota.py`: Redis key `rate_limit:daily:{user_id}:{YYYY-MM-DD}`, TTL 25h; in-memory fallback when REDIS_URL absent. check_quota(user_id, tier), increment_on_success(user_id).
- `/translate`: optional Depends(get_current_user). If user present: tier from plan (pro/business/enterprise = unlimited), quota check before translation; 429 JSONResponse with QUOTA_EXCEEDED and Retry-After on exceed; on success increment quota + DB daily_translation_count, add headers X-Rate-Limit-Remaining and X-Rate-Limit-Reset-At.
- Source of truth: Redis for enforcement; User.daily_translation_count updated on each successful translation for reporting (documented in tier_quota.py). Reset at midnight UTC: automatic in Redis (new key per day).
- Tests: 12 tests in `tests/test_tier_rate_limit.py` (unit + integration for 429, Retry-After, X-Rate-Limit-Remaining, unauthenticated).
- [Code review 2026-02-20] Tests 4.2 and 4.4 added (pro user beyond 5; reset then free user can translate again). auth_update_user called via asyncio.to_thread in main.py to avoid blocking event loop. AC5: for FileResponse, rate-limit info in headers (X-Rate-Limit-Remaining / X-Rate-Limit-Reset-At); story aligned with implementation.

### File List

- middleware/tier_quota.py (new)
- main.py (modified: tier quota check, 429 response, increment, headers, auth_update_user via asyncio.to_thread)
- models/subscription.py (modified: daily_translation_count on User)
- services/auth_service.py (modified: daily_translation_count in _db_user_to_model)
- tests/test_tier_rate_limit.py (new; + tests 4.2 pro beyond 5, 4.4 reset then translate again)
- alembic/env.py (modified: context for DB/migrations used by tier/daily_translation_count)
- database/__init__.py (modified: context for DB)
- database/connection.py (modified: context for DB)
- database/models.py (modified: daily_translation_count, tier on User)
- routes/auth_routes.py (modified: context for get_current_user used by /translate)
- requirements.txt (modified: dependencies for Redis/DB if applicable)
- _bmad-output/implementation-artifacts/sprint-status.yaml (modified: 1-6 → in-progress then review)
- _bmad-output/implementation-artifacts/1-6-middleware-rate-limiting-par-tier.md (modified: tasks, status, Dev Agent Record, File List)
