|
4 | 4 | from typing import Any, Literal |
5 | 5 |
|
6 | 6 | import structlog |
7 | | -from fastapi import APIRouter, Request |
| 7 | +from fastapi import APIRouter, HTTPException, Request |
8 | 8 | from pydantic import BaseModel, Field |
9 | 9 |
|
10 | 10 | from src.serving.api.routers.agent_query import ( |
@@ -186,6 +186,26 @@ async def _execute_query_item(item: BatchItem, req: Request) -> dict[str, Any]: |
186 | 186 |
|
187 | 187 | @router.post("/batch", response_model=BatchResponse) |
188 | 188 | async def batch_query(request: BatchRequest, req: Request) -> BatchResponse: |
| 189 | + # A batch runs one engine op per item but the auth middleware only metered the |
| 190 | + # single HTTP request, so a tenant could drive up to 20x its per-minute budget |
| 191 | + # (concentrated on the expensive NL path) for one token. Charge the remaining |
| 192 | + # items against the same rate-limit bucket and reject the whole batch if the |
| 193 | + # budget cannot absorb them. Skipped when auth is disabled (no tenant_key). |
| 194 | + # (audit S-4) |
| 195 | + tenant_key = getattr(req.state, "tenant_key", None) |
| 196 | + auth_manager = getattr(req.app.state, "auth_manager", None) |
| 197 | + extra_units = len(request.requests) - 1 |
| 198 | + if tenant_key is not None and auth_manager is not None and extra_units > 0: |
| 199 | + within_budget = await auth_manager.charge_rate_limit(tenant_key, extra_units) |
| 200 | + if not within_budget: |
| 201 | + raise HTTPException( |
| 202 | + status_code=429, |
| 203 | + detail=( |
| 204 | + f"Rate limit exceeded: a {len(request.requests)}-item batch costs " |
| 205 | + f"{len(request.requests)} of {tenant_key.rate_limit_rpm} requests/minute." |
| 206 | + ), |
| 207 | + ) |
| 208 | + |
189 | 209 | started_at = time.monotonic() |
190 | 210 | outcomes = await asyncio.gather( |
191 | 211 | *[_execute_item(item, req) for item in request.requests], |
|
0 commit comments