Skip to content

feat: quota deployments per entity type and client address - #1965

Open
LautaroPetaccio wants to merge 1 commit into
mainfrom
feat/deployment-quota-by-ip
Open

feat: quota deployments per entity type and client address#1965
LautaroPetaccio wants to merge 1 commit into
mainfrom
feat/deployment-quota-by-ip

Conversation

@LautaroPetaccio

Copy link
Copy Markdown
Contributor

Why

POST /entities had no bound on how much a single client can deploy over time. The two guards that look like they cover it do not: POST_ENTITIES_RATE_LIMIT_* is a per-client request budget over one 60s window and is blind to entity type, and DEPLOYMENT_RATE_LIMIT_* throttles redeployments of the same pointer rather than one caller's total. One address could deploy 200 distinct scenes a minute indefinitely with every guard reading healthy.

What

A deployment-quota logic component bounding deploy attempts per (client address, entity type) over a minute, hour, day and week. It is enforced in the POST /entities handler rather than at its mount, because the entity type lives inside the uploaded entity file and cannot be known before the multipart parse. The entity is located by its computed hash — the multipart field names are the caller's to choose, so keying on them would let anyone skip the quota by renaming the entity file. The resulting hash map is handed to deployEntity, so nothing is hashed twice.

Every attempt counts, including one that later fails validation, so a client sending garbage cannot deploy for free. A rejection answers 429 with Retry-After and says only when to retry, matching the RateLimitDisclosure.RETRY_AFTER convention the rest of the fleet follows.

Defaults are 60/600/3000/10000 so the server runs unconfigured, with DEPLOYMENT_QUOTA_MAX_PER_{WINDOW}_{ENTITY_TYPE} overrides and DEPLOYMENT_QUOTA_EXEMPT_IPS (addresses and CIDRs) for a backend deploying on behalf of many users from one address. A zero, an unknown entity type, a malformed address, and a ladder whose longer window is tighter than a shorter one all fail startup rather than run misconfigured.

Note for review

The week tier cannot go through IRateLimiterComponent.consume: MAX_WINDOW_SECONDS = 86400 is enforced in resolvePolicy, which both consume() and the middleware go through, as a seconds/milliseconds mix-up guard. Rather than weaken that guard in a shared package for one consumer, counter.ts counts against the cache directly while reusing the package's exported windowOffsetFor, currentWindow, buildCounterKey, encodeIdentity, FALLBACK_IDENTITY and both address helpers — so the window arithmetic, key layout and proxy-hop handling stay shared, and only the increment, the fail-open policy and the fallback divisor are owned here. The cost is that quota decisions no longer land on rate_limiter_requests_total; dcl_content_deployment_quota_attempts_total{entity_type, window, outcome} carries them instead, with allowed / limited / degraded.

Two things worth a second opinion:

  • The default ladder is a proposal, not a measured figure.
  • Because the ladder must be monotonic, a burst can only ever trip the minute window; the longer horizons bind a client that paces itself across windows. That also means the day and week tiers are not reachable within one test run, so they are pinned at the countAttempt level instead.

Counters are in memory, so a restart resets them. The limiter takes any cache with increment, so a durable store can be swapped in later without touching quota logic.

Verification

yarn build, lint and prettier clean. Unit 463/463 across 40 suites; integration 37/37 suites, 323 passed, with no quota rejection leaking into an existing spec. Local integration ran the way CI does (CI=true with an external postgres) because testcontainers/ryuk hangs against Docker 29.4.3 on this machine — unrelated to these changes, but the local testcontainers bootstrap path was not exercised.

POST /entities had no bound on how much one client can deploy over time.
POST_ENTITIES_RATE_LIMIT_* is a request budget over a single 60s window and is blind
to entity type; DEPLOYMENT_RATE_LIMIT_* throttles redeployments of one pointer, not a
caller's total. So one address could deploy 200 distinct scenes a minute indefinitely.

Adds a deployment-quota logic component bounding attempts per (client address, entity
type) over a minute, hour, day and week, enforced in the POST /entities handler — the
entity type lives inside the uploaded entity file, so it cannot be known before the
multipart parse. The entity is located by computed hash, since the multipart field
names are the caller's to choose.

Counting is against the component's own cache rather than IRateLimiterComponent.consume,
which rejects any window longer than a day; the window arithmetic, key layout and
address resolution are still that package's exported ones.

Defaults to 60/600/3000/10000 so the server runs unconfigured, with per-entity-type
overrides and DEPLOYMENT_QUOTA_EXEMPT_IPS for a backend deploying for many users from
one address. A zero, an unknown entity type, a malformed address and a ladder whose
longer window is tighter than a shorter one all fail startup.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant