feat(ratelimit): per-provider bulk token reserve for interactive UI headroom (bookshelf-zvlod) #1130
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-zvlod"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
ClaimTokenBucketSlotWithReserveSQL query: bulk callers blocked attokens < reserve + 1.0, interactive callers drain to 0WaitBulk(ctx, reserve)onDBTokenBucket; falls back toWaitwhenClaimWithReserve == nilorreserve <= 0makeDBBulkWaitForon all burst>1 providers (ComicVine, AniList, Hardcover, Metron) in bothbuildEnrichDepsandbuildBulkLLMSweepDeps; replaces the oldmetronBulkBurst/metronBulkIntervalhack with the same reserve mechanismmetadata.<provider>_bulk_reserve(default=5, clamped to [0, burst-1]); live — no restart neededratelimit-reservecontroller, inline save feedback, CSRF-safe PUT to/settings/ratelimit-reserveTest plan
internal/metadata/ratelimiter— WaitBulk coverage (nil ClaimWithReserve fallback, reserve<=0, immediate grant, deny-then-grant, claim/seed errors, ctx cancellation during sleep)internal/settings— LoadBulkReserve, SaveBulkReserve, BuildSaveBulkReserves, BuildLoadBulkReserves, SaveRateLimitReserveHandler, shell_handler BulkReserves loadinginternal/db— ClaimTokenBucketSlotWithReserve grants when full, denies when drained to reserve floorratelimit_reserve_controller— happy path, CSRF injection, saving states, error handling, 3s auto-clearmake coverage— composite 97.2%, check-coverage OKCloses bead bookshelf-zvlod on merge.
Security / robustness review — PR #1130 (per-provider bulk token reserve)
Reviewed the diff (
origin/main...origin/bd-bookshelf-zvlod) against the rate-limit-weakening, footgun, admin-gating, Metron-regression, and arch-boundary focus areas.1. No rate-limit weakening — CONFIRMED.
ClaimTokenBucketSlotWithReserveuses the identical decrement (GREATEST(0, LEAST(burst, refilled - 1.0))) as the baseClaimTokenBucketSlot; only the WHERE grant threshold is raised from>= 1.0to>= reserve + 1.0. Bulk is therefore strictly stricter, never grants more than base, and the row'sburstcap +refill_per_msare unchanged. Interactive keeps the plainClaim(internal/app/providers.go:159newComicVineWait, unchanged file) draining to 0. Both hit the sametoken_bucket.comicvine/token_bucket.<id>.raterow, so total traffic stays bound by the documented per-provider budget. No path can exceed the cap.2. reserve>=burst footgun — GUARDED.
SaveBulkReserverejectsreserve < 0 || reserve >= burstwithErrValidation(internal/settings/ratelimit_reserve.go);clampReserveclamps an out-of-range stored value to 0 (= disabled = base behaviour) rather than permanently starving bulk; HTML input caps atmax={{.MaxReserve}}(burst-1).reserve=0reproduces pre-feature behaviour. Bulk-starvation self-DoS is prevented at write, load, and UI layers.3. UI admin-only / injection / secrets — CLEAN.
PUT /settings/ratelimit-reserveis wrapped inadminRequired(routes.go); theratelimitstab (and itsBulkReservesdata load) is gated byadminOnlyTabsreturningErrForbiddenfor non-admins beforeloadRateLimitsTabDataruns. Reserve is anint, JSON-decoded, range-validated, and passed as a parameterized sqlcfloat64arg — no injection. Canonicalmetadata-field/metadata-field-inputclasses, no inlinestyle=(CSP-safe). No secrets exposed.4. metronBulkInterval-hack replacement — NO REGRESSION. The old headroom hack (bulk burst clamped 20→5 via divergent
LEASTon the shared row + slowermetronBulkInterval) is removed; bulk Metron now passes the same burst (20) and interval (cfg.MetadataMetronRateLimit) as interactive to the sharedtoken_bucket.metron.raterow, with headroom coming from the reserve floor (default 5). This is a firmer guarantee (≥5 tokens always instantly available to interactive) than the previous probabilistic slow-pacing, and it resolves the carry-over minor #2 (divergent burst/refill on one row). Interactive is not starved worse.5. Key scheme + arch boundary — INTACT. Bulk paths key
token_bucket.<id>.rate(matching the interactive registry) andtoken_bucket.comicvine; no kentd-style collision.internal/metadata/ratelimiter/db_limiter.goimports only stdlib (context,log/slog,math/rand,sync,time) — no workflow-engine import. Fail-open on settings/DB error degrades toreserve=0(loses headroom, never exceeds the cap). All four new/changed test files are black-box (*_testpackages).Findings
[MINOR] internal/app/build_extended_deps.go:2093 — bookdrop import path keeps the plain (no-reserve) wait
buildBookdropImportMetadataDepsstill wires Hardcover (burst>1) viamakeDBWaitFor(base claim, drains to 0). Not a weakening or regression — it matches prior behaviour and is outside the bead's named scope (sweep + bulk enrich) — but this bulk-ish path gets no interactive headroom. Consider folding it into the reserve mechanism in a follow-up if bookdrop import contends with interactive Hardcover fetches.[MINOR] internal/app/build_extended_deps.go:783 — carry-over: Metron burst(20)+20/min refill still permits ~40 req in a pathological rolling 60s vs the documented 20/min window
Flagged on m7n5c/#1128; the reserve does not touch burst/refill so it neither fixes nor worsens this. This PR does improve consistency (interactive and bulk now share identical burst/refill on the row), but the window-vs-burst overshoot is a separate provider-bucket-shape concern. Low risk (single-user, daily-window backstop). Track separately if tightening is wanted.
REVIEW VERDICT: 0 blocker, 0 major, 2 minor
Rate Limit Reserve UI screenshot (ratelimit-reserve-ui)
Rate Limit Reserve UI screenshot (ratelimit-reserve-ui)
Rate Limit Reserve UI screenshot (ratelimit-reserve-ui)
Rate Limit Reserve UI screenshot (ratelimit-reserve-ui)
Rate Limit Reserve UI screenshot (ratelimit-reserve-ui)
bdeb6a5b85ea8bd5c7bb