Skip to content

Differentiate rate limits by tier and bucket per graph - #948

Merged
jfrench9 merged 3 commits into
mainfrom
bugfix/rate-limit-tier-differentiation
Jul 28, 2026
Merged

Differentiate rate limits by tier and bucket per graph#948
jfrench9 merged 3 commits into
mainfrom
bugfix/rate-limit-tier-differentiation

Conversation

@jfrench9

@jfrench9 jfrench9 commented Jul 28, 2026

Copy link
Copy Markdown
Member

Rate limiting worked as burst protection but was undifferentiated by tier — in four independent ways, each of which hid the others. A follow-up commit then makes every surface that describes the limits agree with what the first commit enforces.

What was broken

rate_limiting.py:496 pinned every authenticated user to "ladybug-standard", making the ladybug-large and ladybug-xlarge tables unreachable code
the tables themselves held identical values, so fixing the lookup alone would have changed nothing
api_rate_multiplier read in four places, applied in none/v1/graphs/{id}/limits reported 90/min to Large and 150/min to XLarge, who were served 60
bucket key keyed by user, so a customer with ten graphs shared one budget — per-graph pricing delivering no per-graph throughput

The comment justifying the hardcode said "graph-specific subscriptions are handled at the graph level." The only graph-tier-aware limiter is download_limits.py, which governs monthly backup downloads. graph_scoped_rate_limit_dependency turns out to be a pure alias for the same function.

What unlocked it

LadybugDB writers are not shared. Every customer tier is databases_per_instance: 1, so query load lands on the tenant's own box. The config comment asserted the opposite — "protect shared resources (OpenSearch t3.medium, LadybugDB on m7g/r7g)" — and that false premise is what justified flat limits.

Categories now split by what they actually touch:

Resource Categories Treatment
Tenant's own instance graph_query, graph_read, graph_write, graph_mcp, graph_operator, graph_analytics scale 1×/2×/4× by vCPU, bucketed per graph
Shared OpenSearch / RDS / API graph_search, graph_import, extensions_*, auth, billing flat, bucketed per user

graph_import stays flat deliberately: LadybugDB ingests sequentially regardless of tier, so extra cores buy nothing.

Shared repositories are excluded from per-graph bucketing

sec is one graph every tenant queries. Keying its bucket by graph id would put all of them in a single budget and let one heavy user exhaust it for everyone — worse than the bug being fixed. Those stay user-keyed, which is also what isolates tenants from each other. Subgraphs count too: sec_historical is as shared as sec, so the check uses is_shared_repository_or_subgraph.

Subgraphs draw from the parent's budget

A subgraph of a user graph is the mirror-image case: kg…_dev lives on kg…'s instance and is not separately priced, so it buckets as kg…. Without this, an XLarge tenant with 25 subgraphs would hold 26 independent budgets against one machine.

Failing closed

Tier resolution is Valkey-cached (5 min TTL, one lazily-created client per process) to preserve the limiter's zero-database-access property on the hot path. Cache miss, unknown graph, or lookup error all fall back to the tightest tier. A resolved tier string with no SUBSCRIPTION_RATE_LIMITS entry — ladybug-shared, or a value predating a tier rename — also floors at the fallback tier rather than falling through to the anonymous base table: degradation lands a paying customer on the cheapest paid tier, never below it. Failing open would hand out throughput nobody bought.

The static segments under /v1/graphs (tiers, capacity, extensions) are excluded from graph-id extraction. Latent — those routes use the general limiter — but defense in depth against one of them adopting the subscription-aware dependency and collapsing every caller into a single shared bucket.

api_rate_multiplier

Removed from graph.yml. The API field remains — it's required on two public response models — but is now derived from the limits actually enforced. The follow-up commit finishes the job the first started: /v1/offering and /v1/graphs/tiers were still reading the deleted YAML key and advertising 1.0 for every tier — the inverse of the original drift. Both now call the derived function, and tests pin the derived value at both the catalog level (get_available_tiers for production and staging) and the endpoint level, so neither surface can flatten again. No SDK regen needed.

Reporting surfaces now match enforcement

  • /v1/graphs/{id}/limits reports the enforced tier for shared repositories — it was reporting the base table (20/min) while enforcement grants the user-keyed standard tier (60/min), the exact "reporting a limit we do not honour" this PR exists to end. The same path covers legacy tier strings. Also in that response: credits was permanently null (the block read monthly_credit_limit, a column that does not exist, behind a bare except: pass), and subscription_tier came from a User attribute that also does not exist, reporting ladybug-standard to everyone. It now reports the enforced tier.
  • /v1/graphs/tiers hid Large and XLarge in production by honouring deployment.enabled_default, which answers "is the CloudFormation stack deployed by default" rather than "can a customer buy this" — the same bug docs(taxonomy): correct resync module's account of its own callers #946's sibling fix (1c25fbb) closed in /v1/offering — and fabricated $100/$300/$700 fallback prices matching neither billing nor Stripe. It now mirrors /offering: the billing catalog decides what is listed, and a tier it cannot price is omitted rather than invented.

Deliberately not changed

Standard's numbers are not halved after its m7g.large → m7g.medium resize. Observed production load is ~1% CPU, and cutting existing customers' limits to match a smaller box trades real service for protection admission control (85% of instance CPU/memory) already provides.

Caveat worth stating plainly

The 1×/2×/4× ratios are anchored to vCPU, not measured. The only load data is 27–42% peak CPU on a near-idle three-graph fleet. Admission control remains the real overload backstop; these are a product lever.

Testing

Full suite: 11,413 passed, just test-code clean. The only failures are the two pre-existing test_incremental_materialize cases that reproduce on main.

Tests are weighted toward the failure modes: fail-closed resolution, that the fallback really is the tightest tier — including for tier strings with no limits table, on both the cache and database paths — the 1×/2×/4× and flat-shared invariants, static-segment extraction, and bucketing pinned directly: two users on sec get distinct buckets, one user on two owned graphs gets distinct buckets, and a subgraph shares its parent's bucket. The derived multiplier is pinned at catalog and endpoint level.

Two existing tests were updated rather than fixed: one asserted limit == 60 for Large with the note "1.5x multiplier applied separately", and one pinned the user-keyed bucket. Both encoded the behaviour being corrected.

jfrench9 added 2 commits July 28, 2026 17:28
Rate limiting worked as burst protection but was undifferentiated by tier,
in four independent ways that each hid the others:

- rate_limiting.py pinned every authenticated user to "ladybug-standard",
  so the ladybug-large and ladybug-xlarge tables were unreachable code. The
  comment claimed "graph-specific subscriptions are handled at the graph
  level"; the only graph-tier-aware limiter is download_limits.py, which
  governs monthly backup downloads, not request rate.
- Even reached, those tables held identical values, so differentiating the
  lookup alone would have changed nothing.
- api_rate_multiplier (1.0/1.5/2.5) was read in four places and applied in
  none, so /v1/graphs/{id}/limits reported 90 and 150 per minute to Large
  and XLarge customers who were served 60.
- Buckets were keyed by user, so a customer with ten graphs shared one
  budget — per-graph pricing that delivered no per-graph throughput.

The unlock is that LadybugDB writers are not shared. Every customer tier is
databases_per_instance: 1, so query load lands on the tenant's own box. The
config comment asserted the opposite, which is what justified flat limits.

Categories now split by what they actually touch. DEDICATED_RESOURCE_CATEGORIES
hit the tenant's own instance and scale with vCPU (1x/2x/4x for m7g.medium ->
m7g.large -> r7g.xlarge), bucketed per graph. Everything else — OpenSearch,
extensions RDS, the API tier — stays flat and user-bucketed, so buying graphs
cannot multiply one customer's load on a resource others depend on.

Shared repositories are excluded from per-graph bucketing. `sec` is one graph
every tenant queries: keying its bucket by graph id would put all of them in a
single budget and let one heavy user exhaust it for everyone. Those stay
user-keyed, which is also what isolates tenants. Subgraphs count — sec_historical
is as shared as sec.

Tier resolution is Valkey-cached with a 5 minute TTL to keep the limiter's
zero-database-access property on the hot path, and fails closed to the tightest
tier on cache miss, unknown graph, or lookup error. Failing open would hand out
throughput nobody bought.

api_rate_multiplier is gone from graph.yml. The API field remains — it is
required on two public response models — but is now derived from the limits
actually enforced, so it cannot drift from them again. No SDK regen needed.

/limits reports enforced values rather than 60 x multiplier.

Standard's numbers are deliberately not halved after its m7g.large ->
m7g.medium resize: observed load is ~1% CPU, and cutting existing customers'
limits to match a smaller box trades real service for protection that
admission control (85% of instance CPU/memory) already provides.

Note the 1x/2x/4x ratios are anchored to vCPU, not measured. The only load
data is 27-42% peak CPU on a near-idle three-graph fleet.

Two tests updated rather than fixed: one asserted limit == 60 for Large with
the note "1.5x multiplier applied separately", and one pinned the user-keyed
bucket. Both encoded the behaviour being corrected.
…eft open

Follow-ups in the same theme as the parent commit: what we report must be
what we enforce.

- /v1/offering and /v1/graphs/tiers still read api_rate_multiplier off the
  writer config, where the key no longer exists, so both advertised 1.0 for
  every tier while enforcement gives Large 2x and XLarge 4x — the inverse of
  the drift the derived function was added to end. Both now call the derived
  function, and the derived function returns 1.0 for any tier without its
  own limits table (ladybug-shared, legacy strings), since such graphs are
  enforced at the standard fallback.

- The tier resolver created a fresh Valkey client (and connection pool) per
  request on the hottest path in the API; in production that is a TCP/TLS
  handshake per graph-scoped request. One lazily-created client per process
  now, matching RateLimitCache.

- A resolved tier string with no SUBSCRIPTION_RATE_LIMITS entry fell through
  to the anonymous "base" table — below the FALLBACK_TIER floor the resolver
  promises. Membership is now checked on both the cached and database paths.
  No data migration ever normalized graphs.graph_tier after tier renames, so
  a legacy value in production would have silently dropped a paying customer
  to anonymous limits.

- Subgraphs bucketed by their own id, so an XLarge tenant with 25 subgraphs
  held 26 independent budgets against one machine. kg…_dev now draws from
  kg…'s bucket: a subgraph lives on the parent instance and is not
  separately priced.

- extract_graph_id treated the static /v1/graphs/{tiers,capacity,extensions}
  segments as graph ids. Latent — those routes use the general limiter — but
  one of them adopting the subscription-aware dependency would have
  collapsed every caller into a single shared bucket.

- /v1/graphs/{id}/limits reported the base table (20/min) for shared
  repositories while enforcement grants the user-keyed standard tier
  (60/min); it now reports the enforced tier, which also covers legacy tier
  strings. In the same response, credits was permanently null (the block
  read monthly_credit_limit, a column that does not exist, behind a bare
  except: pass) and subscription_tier came from a User attribute that also
  does not exist, reporting ladybug-standard to everyone.

- /v1/graphs/tiers hid Large and XLarge in production by honouring
  deployment.enabled_default, which answers "is the stack deployed by
  default" rather than "is this sellable" — the bug 1c25fbb fixed in
  /v1/offering — and fabricated $100/$300/$700 fallback prices matching
  neither billing nor Stripe. It now mirrors /v1/offering: fetch every tier,
  let the billing catalog decide what is offered, omit what it cannot price.

Full suite: 11,413 passed; only failures are the two pre-existing
test_incremental_materialize cases that reproduce on main. New tests pin the
derived multiplier at both catalog and endpoint level, the fallback for
unrecognized tier strings, static-segment extraction, and parent bucketing
for subgraphs.
@jfrench9

Copy link
Copy Markdown
Member Author

@claude can you review these changes

@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown

Claude finished @jfrench9's task in 4m 2s —— View job


Reviewing PR #948

  • Gather context (PR body, changed files)
  • Read diff for all changed files (git diff origin/main...HEAD)
  • Review correctness, security, edge cases
  • Post review feedback

Summary

This is a well-scoped, carefully reasoned fix. I traced the four independent bugs the PR describes back through the code and confirmed each is actually closed:

  • rate_limiting.py:503 no longer pins every user to "ladybug-standard"subscription_aware_rate_limit_dependency now calls resolve_graph_tier and only defaults to FALLBACK_TIER (= ladybug-standard) as a real fallback.
  • SUBSCRIPTION_RATE_LIMITS tables for ladybug-large/ladybug-xlarge are now genuinely 2×/4× standard on the dedicated-resource categories (GRAPH_QUERY, GRAPH_READ, GRAPH_WRITE, GRAPH_MCP, GRAPH_OPERATOR, GRAPH_ANALYTICS), verified against robosystems/config/rate_limits.py:176-260.
  • api_rate_multiplier is now derived (GraphTierConfig.get_api_rate_multiplier, robosystems/config/graph_tier.py:315-345) from the same SUBSCRIPTION_RATE_LIMITS table the limiter reads, and both /v1/offering and /v1/graphs/tiers were updated to call it — checked both call sites.
  • Bucketing is now per-graph for dedicated categories via graph_sub:{parent_graph_id} (rate_limiting.py:524-536), with shared repositories (is_shared_repository_or_subgraph) correctly staying user-keyed, and subgraphs correctly resolving to their parent via parse_graph_id before bucketing/tier lookup.

Fail-closed behavior (graph_tier_resolver.py) is solid: cache miss → DB lookup → any exception or missing graph → FALLBACK_TIER; a resolved tier string with no SUBSCRIPTION_RATE_LIMITS entry also floors at FALLBACK_TIER rather than falling through to get_rate_limit's own "unknown tier → base table" fallback (rate_limits.py:283-286), which is exactly the trap the PR calls out. I confirmed _STATIC_GRAPH_SEGMENTS (schema, tiers, capacity, extensions) all correspond to real static routes mounted under /v1/graphs (routers/__init__.py:140, routers/graphs/main.py), so extract_graph_id won't misclassify them.

The /v1/graphs/{id}/limits fixes also check out: credit_limits now reads graph_credits.monthly_allocation/current_balance, which are real columns (models/core/graph/graph_credits.py:69-72) — the old code read monthly_credit_limit, which doesn't exist, silently swallowed by a bare except: pass. subscription_tier now reports the enforced tier instead of a nonexistent User.subscription_tier attribute.

One gap worth flagging

tests/routers/graphs/test_graph_limits.py (pre-existing, untouched by this PR) only covers graph-creation limits, not the GET /v1/graphs/{id}/limits endpoint itself. The new tests are thorough at the config/resolver level (test_graph_tier_resolver.py, test_graph_tier_config.py, test_subscription_rate_limiting.py) and at the /v1/offering + /v1/graphs/tiers endpoint level, but there's no end-to-end assertion that GET /v1/graphs/{id}/limits now actually returns the fixed credits, subscription_tier, and rate_limits.requests_per_minute values for a Large/XLarge graph. Given three separate bugs were fixed in that one handler (routers/graphs/limits.py), a small integration test there would guard against regression more directly than the unit-level coverage does. Not a blocker — the underlying logic (RateLimitConfig.get_rate_limit, GraphCredits fields) is well covered — just a coverage suggestion.

Nit

DEDICATED_RESOURCE_CATEGORIES doesn't include GRAPH_BACKUP/GRAPH_MANAGEMENT/GRAPH_SYNC, so those stay flat and user-keyed even though they also touch the tenant's own instance. That may well be deliberate (backups likely bottleneck on S3 egress, not local CPU) but the PR body doesn't call it out explicitly the way it does for graph_import, so it's worth a one-line confirmation that this was intentional rather than an oversight.

I wasn't able to run just lint/just test in this environment (Bash tool calls requiring uv run were blocked by the sandbox's approval gate) — verified everything by direct code reading and cross-referencing against the new/updated tests instead.

Fix this →

Review follow-up. Three bugs were fixed in this one handler with only
unit-level coverage beneath them; these pin the endpoint itself:

- a ladybug-large graph reports the enforced 120/min, its own tier as
  subscription_tier, and credits populated from monthly_allocation —
  the field that was permanently null behind a bare except
- a shared repository reports the user-keyed standard tier (60/min),
  not the anonymous base table (20/min)
- a legacy graph_tier string floors at the standard fallback

Also documents why GRAPH_BACKUP, GRAPH_MANAGEMENT, and GRAPH_SYNC stay
out of DEDICATED_RESOURCE_CATEGORIES: control-plane operations whose
cost lands on shared infrastructure (S3, platform database, external
providers via the worker pool), so tier vCPU is not their scaling axis.
@jfrench9
jfrench9 merged commit a4663a8 into main Jul 28, 2026
6 checks passed
@jfrench9
jfrench9 deleted the bugfix/rate-limit-tier-differentiation branch July 28, 2026 23:47
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