fix(backend): keep Developer API conversation reads under one shared ceiling - #11682
Conversation
kodjima33
left a comment
There was a problem hiding this comment.
Reasoning is sound — the per-route split gave each Developer API read its own Redis bucket, doubling the aggregate ceiling. Holding the merge until CI is green: PR Metadata Preflight needs a 'Failure-Class:' trailer on the fix: commit, and the Backend unit suite is failing on tests/unit/test_agent_vm_startup.py.
|
Thanks @RatnamOjha — verified this end to end on the current head (791d066), alongside the approval already on record. Code verification, file by file:
One observation, not a blocker: each list/detail read now makes two sequential limiter round-trips (ceiling + route) instead of one. That is consistent with how stacked policies already work here (transcript adds a third), so the trade looks right for the invariant it buys. Remaining CI items, matching what the approval already noted:
The aggregate-ceiling invariant is exactly the structural fix described, and it is now pinned by a test. Once the commit trailer lands and the unrelated flake clears, this should be ready to merge under the approval already on this head. by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with |
…ceiling Splitting conversation list and detail reads into separately tunable policies (BasedHardware#8713) gave each route its own bucket, so a single API key can make 60 list reads *and* 60 detail reads per hour where it previously made 60 in total. The practical exposure delta is small -- detail reads return one conversation each, ~1% of what the list endpoint's 100-record pages already allow, and the polling vector from the original incident is still capped at 60/hr. The reason to fix it is structural: nothing bounds the policy set, so every future dev:conversation_*_read policy silently raises the aggregate again. Add a "dev:conversation_reads_total" policy that every conversation read charges before its per-route budget. The aggregate returns to 60/hr while list, detail and transcript budgets stay independently tunable underneath it, which also settles the review thread asking why detail was given the same value as list. Costs one extra Redis round trip on the two conversation read routes; the existing transcript sub-budget already charges two buckets on the same request. The added test drives both routes in alternation and asserts the aggregate, not a per-route budget, is what rejects the caller. It fails on the prior wiring with "120 != 60". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
791d066 to
73d700b
Compare
|
Thanks both — addressed the two CI items. No code changes to the fix itself. 1. Added a I went through the registry before defaulting. No existing class covers this mode — subdividing a shared quota into per-route policies adds budget rather than partitioning it. The nearest candidates don't fit:
Declared Verified locally against the same commands the manifest runs: 2. Confirmed unrelated. Rebased onto current On the two-round-trip observation — agreed it's the right trade for the invariant, and it stays proportional: the ceiling is charged once per read, so a transcript read is 3 buckets rather than 2, not N. |
Follow-up to the Developer API read hardening from #8713 (landed in af3ab1e).
What this fixes
Splitting conversation reads into per-route policies gave each route its own Redis bucket:
maintodayGET /v1/dev/user/conversationsdev:conversations_read60/hrdev:conversations_read60/hrGET /v1/dev/user/conversations/{id}dev:conversation_detail_readseparate 60/hrEach policy is an independent counter (
rl:{policy}:{prefix}:{uid}:{app_id}:{key_id}), so adding a policy adds budget rather than subdividing it.Honest scoping — this is small
I don't want to oversell it. The practical exposure delta is minor:
backend/routers/developer.py:1241), so 60 list req/hr ≈ 6,000 records/hr vs. the detail path's 60 records/hr — roughly 1% on top.GET /v1/dev/user/conversations?limit=20at ~1,300 req/hr) is the list endpoint, and it is unchanged at 60/hr. That cap still does its job.So this is not a live vulnerability, and "120 vs 60" counts requests, which overstates it.
The reason to fix it is structural: nothing bounds the policy set. Every future
dev:conversation_*_readpolicy silently raises the aggregate again, and no test or comment flags that. This caps it once, in a way that keeps working as policies are added.Approach
Add
dev:conversation_reads_total(60/hr). Every conversation read charges it before its per-route budget:Tradeoff: one extra Redis round trip on the two conversation read routes. The transcript sub-budget already charges two buckets on a single request, so the pattern isn't new — but it is a real cost on a read path, and worth a maintainer's call.
Tests
test_conversation_reads_share_an_aggregate_ceilingdrives both routes in alternation, so neither per-route bucket can be what stops the caller, then asserts the aggregate is. It fails on the current wiring with:Also added
dev:conversation_reads_totalto the existing policy-wiring assertions, and updated the ordered policy list intest_dependency_async_boundaries.py(the shared ceiling is charged first, and still routes throughcritical_executor).Verified per-file the way
backend/test.shruns in CI:test_rate_limiting.py,test_dependency_async_boundaries.py,test_dev_api_conversations_poison.py,test_dev_api_folder_filters.py,test_dev_api_lock_bypass.pyall pass.black --line-length 120 --skip-string-normalizationclean.No existing class in
.github/failure-classes/describes this mode — subdividing a shared quota into per-route policies adds budget rather than partitioning it. Declaringnonerather than minting a class, since reviewers asked for a declaration and not a registry change; happy to switch tonewand add the definition if maintainers would rather have the class tracked.Failure-Class: none