Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 12 additions & 1 deletion app/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import hashlib
import hmac
import json
import logging
import time
Expand Down Expand Up @@ -97,6 +98,16 @@ def _authorize(api_key: str | None) -> None:
raise HTTPException(status_code=401, detail="Invalid API key")


def _rate_limit_identity(api_key: str | None) -> str:
"""Return a non-secret identity that callers cannot control through request fields."""

if api_key is None:
return "development-anonymous"
return hmac.new(
api_key.encode("utf-8"), b"deepsequence-rate-limit-identity:v1", hashlib.sha256
).hexdigest()


def _response(
request: RecommendRequest,
recommendations: list[str],
Expand All @@ -121,7 +132,7 @@ def recommend(
req: RecommendRequest, x_api_key: str | None = Header(default=None)
) -> RecommendResponse:
_authorize(x_api_key)
if not _rate_limiter.allow(req.user_id):
if not _rate_limiter.allow(_rate_limit_identity(x_api_key)):
raise HTTPException(status_code=429, detail="Recommendation rate limit exceeded")
if _runtime is None:
raise HTTPException(status_code=503, detail="Model not initialised")
Expand Down
13 changes: 12 additions & 1 deletion tests/test_serving_controls.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pytest
from fastapi import HTTPException

from app.api.routes import _authorize
from app.api.routes import _authorize, _rate_limit_identity
from app.core.config import settings
from app.core.security import api_key_is_valid
from app.core.serving import RateLimiter, RecommendationCache
Expand All @@ -14,6 +14,17 @@ def test_rate_limiter_fails_closed_after_budget() -> None:
assert limiter.allow("user") is False


def test_rate_limit_identity_is_credential_scoped_and_non_secret() -> None:
limiter = RateLimiter(1)
identity = _rate_limit_identity("shared-credential")

assert identity != "shared-credential"
assert identity == _rate_limit_identity("shared-credential")
assert limiter.allow(identity) is True
# A caller can change user_id, but cannot change this credential-derived bucket.
assert limiter.allow(_rate_limit_identity("shared-credential")) is False


def test_cache_is_model_version_aware() -> None:
cache = RecommendationCache(ttl_seconds=30)
first = cache.key("v1", ["a"], 2)
Expand Down
Loading