feat(quotas): enforce user quotas on chat, agent, scheduled and webhook runs

check_usage now checks the billable user's quota on every request, before
the per-agent 24h limits, which keep applying to traffic through an agent.
Until now a request without an agent key skipped every limit. A refusal is
a 429 with Retry-After and a body naming the budget, usage, limit, the
layer the limit came from and when it resets.

Headless runs check the agent owner's quota before starting. A refused
scheduled run is recorded as budget_exceeded; a refused webhook run returns
a quota_exceeded result instead of raising, so Celery does not retry it.
This commit is contained in:
Alex committed 2026-09-21 12:11:11 +01:00
1 parent 6826313b60
commit 5406550bca
13 files changed
+238 -9

No files matched your search

+6 -2
View File
@@ -103,7 +103,9 @@ class AnswerResource(Resource, BaseAnswerResource):
)
if not processor.decoded_token:
return make_response({"error": "Unauthorized"}, 401)
if error := self.check_usage(processor.agent_config):
if error := self.check_usage(
processor.agent_config, processor.decoded_token
):
return error
stream = self.complete_stream(
question="",
@@ -129,7 +131,9 @@ class AnswerResource(Resource, BaseAnswerResource):
if not processor.decoded_token:
return make_response({"error": "Unauthorized"}, 401)
if error := self.check_usage(processor.agent_config):
if error := self.check_usage(
processor.agent_config, processor.decoded_token
):
return error
should_persist, visibility = resolve_persistence(
+16 -2
View File
@@ -23,6 +23,8 @@ from docsgpt.core.model_utils import (
from docsgpt.core.settings import settings
from docsgpt.error import sanitize_api_error
from docsgpt.llm.llm_creator import LLMCreator
from docsgpt.quotas.http import quota_exceeded_response
from docsgpt.quotas.service import QuotaService
from docsgpt.storage.db.repositories.agents import AgentsRepository
from docsgpt.storage.db.repositories.conversations import (
HeartbeatState,
@@ -112,17 +114,29 @@ class BaseAnswerResource:
prepared.append(item)
return prepared
def check_usage(self, agent_config: Dict) -> Optional[Response]:
"""Check if there is a usage limit and if it is exceeded
def check_usage(
self, agent_config: Dict, decoded_token: Optional[Dict] = None
) -> Optional[Response]:
"""Refuse the request when a usage limit is exhausted.
The billable user's quota is checked first, for every request; the
agent's own 24h token and request limits then apply to traffic that
runs through an agent.
Args:
agent_config: The config dict of agent instance
decoded_token: The request's resolved identity; its ``sub`` is the
billable user.
Returns:
None or Response if either of limits exceeded.
"""
api_key = agent_config.get("user_api_key")
user_id = (decoded_token or {}).get("sub") or agent_config.get("user_id")
exceeded = QuotaService.check(user_id, "agent" if api_key else "direct")
if exceeded is not None:
return quota_exceeded_response(exceeded)
if not api_key:
return None
with db_readonly() as conn:
+6 -2
View File
@@ -115,7 +115,9 @@ class StreamResource(Resource, BaseAnswerResource):
status=401,
mimetype="text/event-stream",
)
if error := self.check_usage(processor.agent_config):
if error := self.check_usage(
processor.agent_config, processor.decoded_token
):
return error
return Response(
with_sse_keepalive(
@@ -151,7 +153,9 @@ class StreamResource(Resource, BaseAnswerResource):
mimetype="text/event-stream",
)
if error := self.check_usage(processor.agent_config):
if error := self.check_usage(
processor.agent_config, processor.decoded_token
):
return error
should_persist, visibility = resolve_persistence(
visibility_flag=data.get("visibility"),
+6
View File
@@ -17,6 +17,7 @@ from sqlalchemy import text as sql_text
from docsgpt.agents.headless_runner import run_agent_headless
from docsgpt.core.settings import settings
from docsgpt.events.publisher import publish_user_event
from docsgpt.quotas.service import QuotaExceededError
from docsgpt.storage.db.base_repository import row_to_dict
from docsgpt.storage.db.engine import get_engine
from docsgpt.storage.db.repositories.conversations import (
@@ -282,6 +283,11 @@ def execute_scheduled_run_body(run_id: str, celery_task_id: Optional[str]) -> Di
outcome = {"answer": "", "tool_calls": [], "sources": [], "thought": ""}
error_type = "timeout"
error_text = "run exceeded soft time limit"
except QuotaExceededError as exc:
# The owner's usage quota is spent; the run never started.
outcome = {"answer": "", "tool_calls": [], "sources": [], "thought": ""}
error_type = "budget_exceeded"
error_text = str(exc)
except Exception as exc:
outcome = {"answer": "", "tool_calls": [], "sources": [], "thought": ""}
error_type = "agent_error"
+1 -1
View File
@@ -338,7 +338,7 @@ def chat_completions():
)
helper = _V1AnswerHelper()
usage_error = helper.check_usage(processor.agent_config)
usage_error = helper.check_usage(processor.agent_config, processor.decoded_token)
if usage_error:
return usage_error