feat: enforce daily quota and surface structured 429 with settings link - #201
feat: enforce daily quota and surface structured 429 with settings link#201neubig wants to merge 28 commits into
Conversation
Add migration 148 with a nullable per-user daily_conversation_limit override column on the user table and a daily_conversation_usage table for atomic per-user, per-UTC-day conversation accounting with a unique (user_id, usage_date) constraint. Register the model and add focused storage tests for the nullable override and uniqueness invariant. No enforcement or API behavior is introduced in this foundation; that lands in subsequent stacked PRs. Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Add GET /api/quota/status returning the authenticated user's effective daily limit, used count, remaining, and next UTC-midnight reset_at. Add a SaaS-only settings page at /settings/quota showing a progress bar and a live HH:MM:SS countdown to the next reset. Includes focused tests for the quota status service covering unlimited, partial, exhausted, and no-usage-today cases. Stacked on feat/daily-conversation-limit (PR #180). Co-authored-by: openhands <openhands@all-hands.dev>
ddaf29c to
de1b36b
Compare
Co-authored-by: openhands <openhands@all-hands.dev>
de1b36b to
abe42cf
Compare
Add migration 149_org_quota with a nullable daily_conversation_limit
column on the org table. Update the quota service to resolve limits
with the precedence: user override → org override → env default → None.
Org-level exemptions use -1 to mean unlimited (NULL inherits the
deployment default). This allows paying SaaS orgs to be exempted from
daily conversation limits while still enforcing limits for other orgs.
Add PUT /api/admin/quota/orgs/{org_id}/quota admin endpoint for
setting or clearing org-level limits. 6 focused tests covering
user precedence, org override, org exemption, env fallback, and
unset/unlimited resolution.
Stacked on feat/quota-usage-page (PR #199).
Co-authored-by: openhands <openhands@all-hands.dev>
…cation Add migration 149 with quota_increase_request table and work_email / work_email_verified_at columns on the user model. Implement: - Free-email domain rejection (exact set from research PR #88) - QuotaIncreaseRequestService: create, approve (idempotent), list pending - POST /api/quota/increase-request: validate work email, persist request, send signed JWS verification email (1-hour expiry), capture PostHog event and set work_email person property - GET /api/quota/verify?token=...: unauthenticated self-service approval via signed token — applies requested limit immediately - GET /api/admin/quota/requests + POST /api/admin/quota/requests/{id}/approve: admin list and approve fallback (superadmin-gated) - Frontend: increase request form on /settings/quota with work email, requested limit (capped at 10x), optional reason, pending/approved states - 14 focused tests covering free-email rejection, limit bounds, duplicates, idempotent approval, and self-service verification Stacked on feat/quota-usage-page (PR #199). Co-authored-by: openhands <openhands@all-hands.dev>
abe42cf to
9f2fe46
Compare
OSS-9996 Limit daily conversations to 20 with support for per-user increases
ContextThe linked Slack discussion converged on:
Slack discussion: https://allhandsai.slack.com/archives/C0A4YK70L3X/p1786657867367199 Proposed designImplement a per-user, per-UTC-day conversation-start quota enforced server-side at the canonical V1 app-conversation creation endpoint ( Policy and data model
The quota owner should be the user, not the organization: the Slack concern is power users creating excessive conversations, while the existing conversation metadata already associates every V1 conversation with both Enforcement
Admin/manual increase pathFor the first rollout, do not add a user-facing request flow or admin API. Support/engineering can set the per-user override directly in the database. The application must not impose a The initial workflow should allow support/engineering to set a user to Frontend behavior
Migration, rollout, and observability
Acceptance criteria
This issue/design was prepared by an AI agent (OpenHands) on behalf of the user. |
Add atomic reserve() and release() methods to DailyConversationQuotaService using PostgreSQL upsert with a count < limit guard. Wire enforcement into the conversation creation path: reserve before starting, release on failure. The 429 response now returns a structured detail object with a human-readable message pointing to /settings/quota for self-service quota increase. On the OpenHands frontend, extend retrieveAxiosErrorMessage and type-guards to extract the message field from structured FastAPI detail objects so the quota error renders correctly in the existing toast-based error display. Add a focused regression test for the structured detail extraction. Stacked on feat/quota-increase-requests (PR #200). Co-authored-by: openhands <openhands@all-hands.dev>
9f2fe46 to
6256000
Compare
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||
Main advanced through migrations 148/149 (budget changes), so this branch's migration reused the already-applied revision id 148. Renumber it to 150 on top of main's 149. Also drop the OH_DAILY_CONVERSATION_LIMIT backfill: stamping the deployment default into every existing user's daily_conversation_limit would take precedence over org-level limits/exemptions and future default changes, permanently pinning those users. NULL now always means 'inherit the effective default at runtime'.
Return undefined explicitly from the countdown effect's no-op branch (consistent-return alongside the interval cleanup return), apply Prettier formatting to the SaaS-only message and countdown span, and swap the unsupported pt-BR translations of the quota keys for the required Catalan (ca) entries.
The revision id '149_org_quota' broke the numeric-prefix/revision match rule and, together with the quota-request migration, created a second Alembic head off revision 148. Renumber to a linear 151 on top of the renumbered 150 so 'alembic upgrade head' resolves a single head.
…se-requests # Conflicts: # frontend/src/i18n/translation.json
- Renumber the quota_increase_request migration to 152 on top of the renumbered org-quota migration 151, restoring a single linear Alembic chain (main already owns revisions 148/149). - Allowlist /api/quota/verify in SetAuthCookieMiddleware: the signed JWS token is the credential, and the link is opened from the user's work email client, usually without an app session. Previously every logged-out click got a 401 before the route ran. - Cap quota increase requests at 10x the org/deployment base default instead of 10x the user's current effective limit, so approved increases can no longer compound into unbounded self-service escalation. The baseline also now respects org-level limits and exemptions via DailyConversationQuotaService.get_default_limit. - Expire pending requests older than the verification-token TTL when the user submits a replacement, and add an admin reject endpoint, so a lost or expired verification email no longer permanently locks the user out of the flow. - Await resolve_analytics_context (it is async); the PostHog capture and work_email person property previously never fired because the sync call handed a coroutine to the analytics service and the error was swallowed. - Populate work_email and work_email_verified in /api/quota/status instead of returning hardcoded None/False. - Fix ESLint/prettier errors in the quota frontend files (define QuotaIncreaseRequestForm before use, import formatting) and replace the unsupported pt-BR translations with the required Catalan entries.
- Keep retrieveAxiosErrorMessage returning string: the structured-detail branch now falls back to error.message instead of null, which had widened the return type to string | null and broke compilation at existing call sites (query-client-config, changes-tab). Also satisfy prefer-destructuring in the new type guard. - Run quota reserve/release on a dedicated short-lived session instead of the request-scoped db_session. The old release path executed and committed on a session that could be in a failed-transaction state after a start error (masking the original exception, skipping the session/httpx cleanup that follows, and leaking the kept-open connections) or could commit unrelated pending writes from the partially executed start flow. Release is now best-effort with its own error handling so the original failure always propagates and cleanup always runs. - reserve() now reports whether a slot was actually consumed, so a failed start for an unlimited user no longer triggers a spurious decrement.
The enterprise ruff config enforces single-quoted strings; this literal was the one remaining violation failing the enterprise lint job.
Sort the quota router imports, drop the unused AsyncSession and patch imports. These were previously masked in CI because the enterprise lint job failed to build its mypy environment before ruff could report.
…ions # Conflicts: # enterprise/saas_server.py # enterprise/server/routes/quota.py
Drop the unused logger import and apply ruff formatting in the quota routes and org quota test; restore the patch import the upstream merge removed while this branch's env-clearing test still uses it. These were previously masked in CI by the enterprise lint job's environment failure.
…se-requests # Conflicts: # enterprise/server/routes/quota.py
Export QuotaIncreaseRequest via __all__ (matching the module's pattern and resolving the unused-import error), sort the storage/conftest imports, wrap the long admin-router registration line, and apply ruff formatting to the free-email domain test. Previously masked in CI by the enterprise lint job's environment failure.
Enforces the daily conversation quota at conversation creation time and ensures the blocked error is properly displayed to users.
Backend
reserve(): PostgreSQL upsert withconversation_count < limitguard; raises HTTP 429 with structured detail when limit is reachedrelease(): Decrements usage when conversation start failsapp_conversation_router.py: Reserves before starting, releases on failuremessagefield pointing to /settings/quotaOpenHands Frontend (error display)
retrieveAxiosErrorMessageextended to extractdetail.messagefrom structured FastAPI error responsestype-guards.ts: NewisAxiosErrorWithStructuredDetailguardStacking
This pull request was created by an AI agent (OpenHands) on behalf of the user.
Enterprise server image for this PR: