Repository navigation
Replies: 1 comment 2 replies
|
Thank you for the detailed proposal. Requests authenticated with an API token now also count against an account budget. It applies per endpoint, with a default of twice the per-token limit, and is set with An account can hold at most 10 active tokens by default ( Shared storage for the counters is set with |
Uh oh!
There was an error while loading. Please reload this page.
Description
API rate limits exist, but they are counted per token, not per account. A user with five tokens gets five independent budgets, and nothing in the application caps how many tokens an account may hold, so the effective ceiling for one user is "as many requests as they care to mint tokens for".
On a shared instance with dozens of programmatic clients this is the difference between a limit and a suggestion, and the currency being spent is not just requests: uploads occupy gunicorn threads, fill disk, and queue transcription jobs that cost real money at the ASR provider.
Measured on a gunicorn deployment that showed three independent counters, a single token allowed 353 successful requests in one minute against a documented limit of 120, and a token holder can multiply that figure again by creating more tokens. The request is for a second, account-wide budget that sits on top of the existing per-token budget, so that adding a token cannot add throughput.
Affected Version
v0.10.15-alpha
Requested Feature
Add a per-user rate limit that every API request authenticated by a token of that user consumes, regardless of which token carried it, and keep the current per-token limit unchanged. The two budgets should be configurable separately, with per-user defaults high enough that a legitimate client using one token sees no behavior change (for example twice the per-token default), and with a clear 429 the moment either budget is exhausted.
Alongside it, add a configurable cap on active tokens per account, because an account-wide budget makes the token cap a secondary defense rather than the only one.
Optionally, make the limiter storage configurable through
RATELIMIT_STORAGE_URIso the aggregate is exact across worker processes instead of being counted once per process.Proposed Implementation
require_scopeinsrc/utils/token_auth.py:191-220. All 60 API v1 routes (src/api/api_v1.py:51) and all 10 webhook routes (src/api/webhooks.py:47) are decorated with it, andtests/test_api_v1_scope_coverage.pyfails if a new route forgets it, so a second limiter added there covers the whole surface without touching a single route._USER_RATE_LIMITStable next to_RATE_LIMITS(src/utils/token_auth.py:162-166), keyed by the same read/write/process categories, backed byAPI_USER_RATE_LIMIT_READ,API_USER_RATE_LIMIT_WRITEandAPI_USER_RATE_LIMIT_PROCESSwith defaults of240 per minute,60 per minuteand20 per minute.user_rate_key()next totoken_rate_key()(src/utils/token_auth.py:177-183), returninguser:<api_token.user_id>. The id is already on theAPITokenrow loaded for the request, so this costs no extra query. Session traffic keeps its existing per-address limits and is not affected.require_scope, giving each its own scope string so the counters do not merge:create_token(src/api/tokens.py:82-140): count rows for the user whererevokedis false andexpires_atis null or in the future (src/models/api_token.py:48-55), and refuse with409andcode: token_limit_reachedaboveAPI_MAX_TOKENS_PER_USER(default 10,0meaning unlimited). Revoking a token frees a slot immediately, so the message can say so.storage_uri=os.environ.get('RATELIMIT_STORAGE_URI')when constructing the limiter (src/app.py:175-181). Without a shared backend, Flask-Limiter keeps a counter per process (src/app.py:553), so an account-wide limit on the stock 3-worker image behaves as three times its configured value. Thememory://caveat is explicit in the Flask-Limiter documentation, which notes that each process gets its own storage.docs/user-guide/api-reference.md:2027-2037and the new environment variables indocs/user-guide/api-tokens.md, including the note that a shared storage backend is what makes the account-wide figure exact.tests/test_api_token_scopes.pywith a case that creates two tokens for one user and asserts the second 429 arrives at the user budget, not the token budget, and a case that the token cap returns 409 and that revocation frees a slot.Expected Behavior
API_MAX_TOKENS_PER_USERreturns409withcode: token_limit_reachedand a message that names the current cap and points at revoking an unused token.RATELIMIT_STORAGE_URIset to a shared backend, 400 requests in a minute from one account yield 240 successes and 160 rejections; with the default in-memory storage the same test yields roughly three times that, and the documentation says so.1000 per hourand5000 per day(src/app.py:178), unchanged.Root Cause Analysis
The limiter is keyed on the token row, not the account:
token_rate_keyreturnsf"token:{api_token.id}"(src/utils/token_auth.py:177-183), andrequire_scopeapplies exactly one limit built from that key (src/utils/token_auth.py:191-220). Token creation has a rate limit of10 per hour(src/api/tokens.py:84) but no cap on the number of active tokens, andcreate_tokennever counts existing rows (src/api/tokens.py:82-140).The result is an account-wide throughput of
120 per minute per token, which is unbounded in the number of tokens. A secondary multiplier comes from the storage backend:limiter.init_appuses the default in-memory storage (src/app.py:553), the image starts three workers (Dockerfile:148), and each holds its own counters.A fresh minute of 400 sequential requests against one token returned 353 successes and 47 rejections, and
X-RateLimit-Remainingcycled between three separate values in the same window, which is the signature of three per-process buckets rather than one shared budget.The current behavior deviates from the intended design in two ways: the documented per-token limit understates what the deployment actually allows, and no code path aggregates traffic for an account, so the application has no way to answer "how much is this user doing right now".
Files and code paths involved:
src/utils/token_auth.py(limits, keys,require_scope),src/app.py(limiter construction and defaults),src/api/tokens.py(token creation),Dockerfile(worker count),docs/user-guide/api-reference.md(published limits).Additional Context
The ASR Voice Recorder route carries its token inside the multipart body (
src/api/api_v1.py:3727-3729), sois_token_request()is false there and the per-user layer, like the current per-token layer, will not see those requests; that route keeps its own size-weighted per-address limit of 520 units per minute. This is a pre-existing boundary, not something this request needs to solve, but it should be documented next to the new limits so the behavior is not surprising.Response headers need a note in the documentation. Flask-Limiter emits
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Resetfor one of the applicable limits when a request has not breached any of them, and both budgets here are per-minute, so the header will report one of the two figures. The 429 body names the exhausted limit, and the tests should assert on that body rather than on the header values.Backward compatibility is the reason for the doubled defaults: any existing single-token client stays under 240/60/20 without changing configuration, while an account that deliberately splits work across many tokens is the only one that notices. The two limits compose with the existing scopes, so a scoped token already restricted to
readconsumes only the read budget.The
memory://default also explains why this feature and a shared storage backend are worth shipping together: an account-wide limit computed per process is three times its configured value on the stock image, which would make the new knob misleading in exactly the deployment where it matters most.All reactions