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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
env:
DMA_API_KEY: ci-container-validation-key
run: |
docker compose build --builder cloud
docker compose build
docker compose up -d
for attempt in {1..15}; do
if curl --fail --silent http://127.0.0.1:8000/healthz; then break; fi
Expand Down
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ WORKDIR /app
COPY services/dma-api/pyproject.toml services/dma-api/uv.lock ./
COPY services/dma-api/src ./src

RUN pip install --no-cache-dir uv && uv pip install --locked --no-cache .
RUN pip install --no-cache-dir uv \
&& uv export --frozen --no-dev --no-emit-project --output-file requirements.txt \
&& uv pip install --system --no-cache -r requirements.txt . \
&& rm requirements.txt

RUN useradd --create-home --uid 10001 dma \
&& mkdir /data \
Expand Down
4 changes: 2 additions & 2 deletions benchmarks/results/v0.1-failures.jsonl
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{"actual_result_ids": [], "case_id": "semantic-024", "category": "semantic_preference", "excluded_ids": [], "expected_ids": ["semantic-024-mem-01"], "query": "how should benchmark results be reported?", "should_recall": true, "stale_ids": []}
{"actual_result_ids": [], "case_id": "procedural-004", "category": "procedural_workflow", "excluded_ids": [], "expected_ids": ["procedural-004-mem-01"], "query": "what should adapter work preserve?", "should_recall": true, "stale_ids": []}
{"actual_result_ids": ["distractor-001-mem-02", "distractor-001-mem-01"], "case_id": "distractor-001", "category": "distractor", "excluded_ids": ["distractor-001-mem-01"], "expected_ids": ["distractor-001-mem-02"], "query": "what frontend framework does the user prefer?", "should_recall": true, "stale_ids": []}
{"actual_result_ids": ["distractor-007-mem-01", "distractor-007-mem-02"], "case_id": "distractor-007", "category": "distractor", "excluded_ids": ["distractor-007-mem-01"], "expected_ids": ["distractor-007-mem-02"], "query": "what proves framework-agnostic support?", "should_recall": true, "stale_ids": []}
{"actual_result_ids": ["distractor-007-mem-02", "distractor-007-mem-01"], "case_id": "distractor-007", "category": "distractor", "excluded_ids": ["distractor-007-mem-01"], "expected_ids": ["distractor-007-mem-02"], "query": "what proves framework-agnostic support?", "should_recall": true, "stale_ids": []}
{"actual_result_ids": ["distractor-008-mem-02", "distractor-008-mem-01"], "case_id": "distractor-008", "category": "distractor", "excluded_ids": ["distractor-008-mem-01"], "expected_ids": ["distractor-008-mem-02"], "query": "what metric evaluates classification?", "should_recall": true, "stale_ids": []}
{"actual_result_ids": ["distractor-012-mem-02", "distractor-012-mem-01"], "case_id": "distractor-012", "category": "distractor", "excluded_ids": ["distractor-012-mem-01"], "expected_ids": ["distractor-012-mem-02"], "query": "what command evaluates retrieval quality?", "should_recall": true, "stale_ids": []}
{"actual_result_ids": [], "case_id": "distractor-015", "category": "distractor", "excluded_ids": ["distractor-015-mem-02"], "expected_ids": ["distractor-015-mem-01"], "query": "what cannot be overwritten after release?", "should_recall": true, "stale_ids": []}
{"actual_result_ids": ["distractor-016-mem-02", "distractor-016-mem-01"], "case_id": "distractor-016", "category": "distractor", "excluded_ids": ["distractor-016-mem-01"], "expected_ids": ["distractor-016-mem-02"], "query": "what package contains the LangGraph adapter?", "should_recall": true, "stale_ids": []}
{"actual_result_ids": ["distractor-017-mem-01", "distractor-017-mem-02"], "case_id": "distractor-017", "category": "distractor", "excluded_ids": ["distractor-017-mem-02"], "expected_ids": ["distractor-017-mem-01"], "query": "what package exposes MCP tools?", "should_recall": true, "stale_ids": []}
{"actual_result_ids": [], "case_id": "conflict-010", "category": "conflict_update", "excluded_ids": ["conflict-010-mem-01"], "expected_ids": ["conflict-010-mem-02"], "query": "how should packages be published now?", "should_recall": true, "stale_ids": []}
Expand Down
14 changes: 12 additions & 2 deletions benchmarks/runner/memory_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,21 @@
from typing import Any

from dma_api.models import MemoryType
from dma_api.repository import MemoryRecord, SQLiteMemoryRepository
from dma_api.repository import (
AnalyzerKind,
MemoryRecord,
SQLiteMemoryRepository,
get_analyzer,
)

DEFAULT_DATASET = Path("benchmarks/datasets/memory-eval-v0.1.jsonl")
DEFAULT_NOW = datetime.fromisoformat("2026-08-01T00:00:00+00:00")
TENANT_ID = "memory-eval-tenant"
# Use DomainAnalyzer for benchmark parity with the evaluation corpus.
# The DomainAnalyzer includes DMA-specific query expansions that the
# eval dataset was designed for. The default PLAIN analyzer has no
# expansions and uses prefix-only matching to avoid false positives.
BENCHMARK_ANALYZER = get_analyzer(AnalyzerKind.DOMAIN)


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -70,7 +80,7 @@ def _failures_to_set(failures: list[dict[str, Any]]) -> set[str]:


def _run_case(case: dict[str, Any], database_path: Path, *, limit: int) -> MemoryEvalResult:
repository = SQLiteMemoryRepository(database_path)
repository = SQLiteMemoryRepository(database_path, analyzer=BENCHMARK_ANALYZER)
repository.initialize()
for index, memory in enumerate(case["memories"]):
timestamp = DEFAULT_NOW.replace(microsecond=index)
Expand Down
51 changes: 50 additions & 1 deletion services/dma-api/src/dma_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,40 @@
from __future__ import annotations

import os
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path

from dma_api.repository import AnalyzerKind


@dataclass(frozen=True, slots=True)
class AuthLimits:
"""Rate-limiting bounds for bearer-auth attempts per source (IP)."""

max_attempts: int = 5
window_seconds: int = 60
lockout_seconds: int = 300
max_tracked_sources: int = 10_000

def __post_init__(self) -> None:
for name in ("max_attempts", "window_seconds", "lockout_seconds", "max_tracked_sources"):
value = getattr(self, name)
if value < 1:
raise ValueError(f"{name} must be a positive integer, got {value}")


def _positive_int_env(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
value = int(raw)
except ValueError as error:
raise ValueError(f"{name} must be a positive integer, got {raw!r}") from error
if value < 1:
raise ValueError(f"{name} must be a positive integer, got {value}")
return value


@dataclass(frozen=True, slots=True)
class Settings:
Expand All @@ -15,15 +46,33 @@ class Settings:
api_key: str = "dma-local-development-key"
tenant_id: str = "local"
environment: str = "development"
analyzer_kind: AnalyzerKind = AnalyzerKind.PLAIN
auth_limits: AuthLimits = field(default_factory=AuthLimits)
trust_forwarded_for: bool = False

@classmethod
def from_env(cls) -> Settings:
"""Load runtime configuration without ever logging secret values."""
limits = AuthLimits(
max_attempts=_positive_int_env("DMA_AUTH_MAX_ATTEMPTS", 5),
window_seconds=_positive_int_env("DMA_AUTH_WINDOW_SECONDS", 60),
lockout_seconds=_positive_int_env("DMA_AUTH_LOCKOUT_SECONDS", 300),
max_tracked_sources=_positive_int_env("DMA_AUTH_MAX_TRACKED_SOURCES", 10_000),
)
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
analyzer_kind_str = os.getenv("DMA_ANALYZER_KIND", "plain").lower()
try:
analyzer_kind = AnalyzerKind(analyzer_kind_str)
except ValueError:
analyzer_kind = AnalyzerKind.PLAIN
settings = cls(
database_path=Path(os.getenv("DMA_DATABASE_PATH", "./dma.db")),
api_key=os.getenv("DMA_API_KEY", "dma-local-development-key"),
tenant_id=os.getenv("DMA_TENANT_ID", "local"),
environment=os.getenv("DMA_ENVIRONMENT", "development"),
analyzer_kind=analyzer_kind,
auth_limits=limits,
trust_forwarded_for=os.getenv("DMA_TRUST_FORWARDED_FOR", "false").lower()
in {"1", "true", "yes"},
)
if settings.environment == "production" and settings.api_key == "dma-local-development-key":
raise ValueError("DMA_API_KEY must be explicitly configured in production")
Expand Down
138 changes: 132 additions & 6 deletions services/dma-api/src/dma_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,29 @@

from __future__ import annotations

import logging
import re
import secrets
import threading
import time
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from datetime import UTC, datetime
from uuid import uuid4

from fastapi import Depends, FastAPI, Header, HTTPException, Path, Query, Response, status
from fastapi import (
Depends,
FastAPI,
Header,
HTTPException,
Path,
Query,
Request,
Response,
status,
)

from dma_api.config import Settings
from dma_api.config import AuthLimits, Settings
from dma_api.models import (
MemoryExplanation,
MemoryPage,
Expand All @@ -22,13 +36,109 @@
RememberRequest,
RetrievalExplanation,
)
from dma_api.repository import MemoryRecord, SQLiteMemoryRepository
from dma_api.repository import MemoryRecord, SQLiteMemoryRepository, get_analyzer

logger = logging.getLogger("dma_api.auth")


@dataclass
class _SourceRecord:
timestamps: list[float] = field(default_factory=list)
locked_until: float = 0.0


class InMemoryRateLimiter:
"""Per-source sliding-window rate limiter with lockout for failed auth."""

def __init__(self, limits: AuthLimits) -> None:
self._max_attempts = limits.max_attempts
self._window = limits.window_seconds
self._lockout = limits.lockout_seconds
self._max_sources = limits.max_tracked_sources
self._sources: dict[str, _SourceRecord] = {}
self._lock = threading.Lock()

def is_locked_out(self, source: str) -> bool:
with self._lock:
record = self._sources.get(source)
if record is None:
return False
return record.locked_until > time.monotonic()

def record_failure(self, source: str) -> None:
now = time.monotonic()
with self._lock:
self._evict_stale(now)
record = self._sources.setdefault(source, _SourceRecord())
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
cutoff = now - self._window
record.timestamps = [t for t in record.timestamps if t > cutoff]
record.timestamps.append(now)
if len(record.timestamps) >= self._max_attempts:
record.locked_until = now + self._lockout
attempts_in_window = len(record.timestamps)
logger.warning(
"auth_failure source=%s total_in_window=%d",
source,
attempts_in_window,
)

def record_success(self, source: str) -> None:
with self._lock:
self._sources.pop(source, None)

def tracked_sources(self) -> int:
with self._lock:
return len(self._sources)

def _evict_stale(self, now: float) -> None:
"""Drop sources without in-window failures or an active lockout.

Must be called while holding ``self._lock``. When eviction alone cannot
keep the map under ``max_tracked_sources``, the least recently active
sources are dropped so source churn cannot grow memory without bound.
"""
cutoff = now - self._window
for key, record in list(self._sources.items()):
if record.locked_until > now:
continue
if not any(timestamp > cutoff for timestamp in record.timestamps):
del self._sources[key]
overflow = len(self._sources) - self._max_sources + 1
if overflow <= 0:
return
stalest = sorted(self._sources, key=self._last_activity)[:overflow]
for key in stalest:
del self._sources[key]
Comment on lines +106 to +111

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Source churn cancels active lockouts

At the tracking cap, _evict_stale can remove locked sources. Rotating enough source identities lets authentication attempts resume before lockout expiry.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


def _last_activity(self, source: str) -> float:
record = self._sources[source]
return max(record.locked_until, max(record.timestamps, default=0.0))


def _client_source(
request: Request, x_forwarded_for: str | None, *, trust_forwarded_for: bool
) -> str:
"""Identify the caller for rate limiting.

``X-Forwarded-For`` is honoured only when the deployment declares that it
runs behind a trusted proxy; otherwise a client could rotate the header to
win a fresh failure counter for every guess. The transport peer address is
the default, so direct callers are never pooled into one shared bucket.
"""
if trust_forwarded_for and x_forwarded_for:
forwarded = x_forwarded_for.split(",")[0].strip()
if forwarded:
return forwarded
client = request.client
return client.host if client is not None else "unknown"


def create_app(settings: Settings | None = None) -> FastAPI:
"""Create an independently configurable API application."""
runtime_settings = settings or Settings()
repository = SQLiteMemoryRepository(runtime_settings.database_path)
analyzer = get_analyzer(runtime_settings.analyzer_kind)
repository = SQLiteMemoryRepository(runtime_settings.database_path, analyzer=analyzer)
rate_limiter = InMemoryRateLimiter(runtime_settings.auth_limits)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Process-local counters weaken lockouts

Each create_app instance owns an independent limiter. Multiple workers, replicas, or restarts split or erase failed-attempt counts, weakening configured lockouts.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


@asynccontextmanager
async def lifespan(_: FastAPI):
Expand All @@ -41,10 +151,26 @@ async def lifespan(_: FastAPI):
def healthz() -> dict[str, str]:
return {"status": "ok"}

def authenticate(authorization: str | None = Header(default=None)) -> str:
def authenticate(
request: Request,
authorization: str | None = Header(default=None),
x_forwarded_for: str | None = Header(default=None, alias="X-Forwarded-For"),
) -> str:
source = _client_source(
request, x_forwarded_for, trust_forwarded_for=runtime_settings.trust_forwarded_for
)
if rate_limiter.is_locked_out(source):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="too many failed authentication attempts; try again later",
)
expected = f"Bearer {runtime_settings.api_key}"
if authorization is None or not secrets.compare_digest(authorization, expected):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid API key")
rate_limiter.record_failure(source)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid API key"
)
rate_limiter.record_success(source)
return runtime_settings.tenant_id

@app.post("/v1/memories", response_model=MemoryResponse, status_code=status.HTTP_201_CREATED)
Expand Down
Loading
Loading